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.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
|
+
// C4: Per-stream sequence counter for message ordering
|
|
314
|
+
_sequenceCounters = /* @__PURE__ */ new Map();
|
|
315
|
+
// C3: 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
|
|
@@ -37024,6 +37133,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
|
|
|
37024
37133
|
_badMessageCount = 0;
|
|
37025
37134
|
_lastActivity = 0;
|
|
37026
37135
|
_outboundBuffer = [];
|
|
37136
|
+
_offlineQueue = [];
|
|
37027
37137
|
_middleware;
|
|
37028
37138
|
_validators = [];
|
|
37029
37139
|
_strictProtocols = null;
|
|
@@ -37178,6 +37288,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
|
|
|
37178
37288
|
}
|
|
37179
37289
|
this._attachWebsocket(ws);
|
|
37180
37290
|
this._startPing();
|
|
37291
|
+
this._flushOfflineQueue();
|
|
37181
37292
|
if (this._outboundBuffer.length > 0) {
|
|
37182
37293
|
const buffer = this._outboundBuffer;
|
|
37183
37294
|
this._outboundBuffer = [];
|
|
@@ -37359,8 +37470,32 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
|
|
|
37359
37470
|
options = args[2] ?? {};
|
|
37360
37471
|
}
|
|
37361
37472
|
if (this._state !== OPEN) {
|
|
37473
|
+
if (this._options.offlineQueue && (this._state === CLOSED || this._state === CONNECTING)) {
|
|
37474
|
+
return new Promise((resolve, reject) => {
|
|
37475
|
+
const maxSize = this._options.offlineQueueMaxSize ?? 100;
|
|
37476
|
+
if (this._offlineQueue.length >= maxSize) {
|
|
37477
|
+
this._offlineQueue.shift();
|
|
37478
|
+
this._logger?.warn?.(
|
|
37479
|
+
"Offline queue full \u2014 dropping oldest message",
|
|
37480
|
+
{
|
|
37481
|
+
method,
|
|
37482
|
+
queueSize: this._offlineQueue.length
|
|
37483
|
+
}
|
|
37484
|
+
);
|
|
37485
|
+
}
|
|
37486
|
+
this._offlineQueue.push({ method, params, options, resolve, reject });
|
|
37487
|
+
this._logger?.debug?.("Call queued offline", {
|
|
37488
|
+
method,
|
|
37489
|
+
queueSize: this._offlineQueue.length
|
|
37490
|
+
});
|
|
37491
|
+
});
|
|
37492
|
+
}
|
|
37362
37493
|
throw new Error(`Cannot call: client is in state ${this._state}`);
|
|
37363
37494
|
}
|
|
37495
|
+
const maxRetries = options.retries ?? 0;
|
|
37496
|
+
if (maxRetries > 0) {
|
|
37497
|
+
return this._callWithRetry(method, params, options, maxRetries);
|
|
37498
|
+
}
|
|
37364
37499
|
return this._callQueue.push(() => this._sendCall(method, params, options));
|
|
37365
37500
|
}
|
|
37366
37501
|
async safeCall(...args) {
|
|
@@ -37385,7 +37520,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
|
|
|
37385
37520
|
}
|
|
37386
37521
|
}
|
|
37387
37522
|
async _sendCall(method, params, options) {
|
|
37388
|
-
const msgId = createId();
|
|
37523
|
+
const msgId = options.idempotencyKey ?? createId();
|
|
37389
37524
|
const timeoutMs = options.timeoutMs ?? this._options.callTimeoutMs;
|
|
37390
37525
|
const ctx = {
|
|
37391
37526
|
type: "outgoing_call",
|
|
@@ -37433,7 +37568,7 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
|
|
|
37433
37568
|
sentAt: Date.now()
|
|
37434
37569
|
});
|
|
37435
37570
|
if (this._ws?.readyState === WebSocket.OPEN) {
|
|
37436
|
-
this._ws
|
|
37571
|
+
this._safeSend(this._ws, messageStr, (err) => {
|
|
37437
37572
|
if (err) {
|
|
37438
37573
|
clearTimeout(timeoutHandle);
|
|
37439
37574
|
this._pendingCalls.delete(msgId);
|
|
@@ -37780,6 +37915,87 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
|
|
|
37780
37915
|
}
|
|
37781
37916
|
}, delayMs);
|
|
37782
37917
|
}
|
|
37918
|
+
// ─── Internal: Offline Queue ──────────────────────────────────
|
|
37919
|
+
/**
|
|
37920
|
+
* Atomically drains the offline queue and sends each message via _sendCall.
|
|
37921
|
+
* Uses splice(0) to prevent re-entry bugs (double billing) if the connection
|
|
37922
|
+
* drops again mid-flush — the queue is empty before any sends begin.
|
|
37923
|
+
*/
|
|
37924
|
+
_flushOfflineQueue() {
|
|
37925
|
+
if (this._offlineQueue.length === 0) return;
|
|
37926
|
+
const snapshot = this._offlineQueue.splice(0, this._offlineQueue.length);
|
|
37927
|
+
this._logger?.info?.("Flushing offline queue", { count: snapshot.length });
|
|
37928
|
+
for (const entry of snapshot) {
|
|
37929
|
+
this._callQueue.push(() => this._sendCall(entry.method, entry.params, entry.options)).then(entry.resolve).catch(entry.reject);
|
|
37930
|
+
}
|
|
37931
|
+
}
|
|
37932
|
+
// ─── Internal: Call Retry with Full Jitter ───────────────────
|
|
37933
|
+
/**
|
|
37934
|
+
* Retry wrapper using Full Jitter exponential backoff.
|
|
37935
|
+
* delay = random(0, min(retryMaxDelayMs, retryDelayMs * 2^attempt))
|
|
37936
|
+
* Only retries on TimeoutError — all other errors propagate immediately.
|
|
37937
|
+
*/
|
|
37938
|
+
async _callWithRetry(method, params, options, maxRetries) {
|
|
37939
|
+
const baseDelay = options.retryDelayMs ?? 1e3;
|
|
37940
|
+
const maxDelay = options.retryMaxDelayMs ?? 3e4;
|
|
37941
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
37942
|
+
try {
|
|
37943
|
+
return await this._callQueue.push(
|
|
37944
|
+
() => this._sendCall(method, params, options)
|
|
37945
|
+
);
|
|
37946
|
+
} catch (err) {
|
|
37947
|
+
if (attempt === maxRetries || !(err instanceof TimeoutError)) {
|
|
37948
|
+
throw err;
|
|
37949
|
+
}
|
|
37950
|
+
const expDelay = Math.min(maxDelay, baseDelay * 2 ** attempt);
|
|
37951
|
+
const jitteredDelay = Math.random() * expDelay;
|
|
37952
|
+
this._logger?.warn?.("Call retry", {
|
|
37953
|
+
method,
|
|
37954
|
+
attempt: attempt + 1,
|
|
37955
|
+
maxRetries,
|
|
37956
|
+
delayMs: Math.round(jitteredDelay)
|
|
37957
|
+
});
|
|
37958
|
+
await new Promise((r) => setTimeout(r, jitteredDelay));
|
|
37959
|
+
}
|
|
37960
|
+
}
|
|
37961
|
+
throw new Error("Retry exhausted");
|
|
37962
|
+
}
|
|
37963
|
+
// ─── Internal: Backpressure-Aware Send ───────────────────────
|
|
37964
|
+
/** Maximum bytes allowed in the ws send buffer before applying backpressure (512KB) */
|
|
37965
|
+
static _BACKPRESSURE_THRESHOLD = 512 * 1024;
|
|
37966
|
+
/**
|
|
37967
|
+
* Wraps ws.send() with backpressure protection.
|
|
37968
|
+
* If bufferedAmount exceeds the threshold, waits for the buffer to drain
|
|
37969
|
+
* before sending. Prevents OOM on slow 2G/3G charger connections.
|
|
37970
|
+
*/
|
|
37971
|
+
_safeSend(ws, data, cb) {
|
|
37972
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
37973
|
+
cb?.(new Error("WebSocket is not open"));
|
|
37974
|
+
return;
|
|
37975
|
+
}
|
|
37976
|
+
if (ws.bufferedAmount > _OCPPClient._BACKPRESSURE_THRESHOLD) {
|
|
37977
|
+
this._logger?.warn?.("Backpressure \u2014 pausing send", {
|
|
37978
|
+
bufferedAmount: ws.bufferedAmount,
|
|
37979
|
+
threshold: _OCPPClient._BACKPRESSURE_THRESHOLD
|
|
37980
|
+
});
|
|
37981
|
+
this.emit("backpressure", { bufferedAmount: ws.bufferedAmount });
|
|
37982
|
+
let waited = 0;
|
|
37983
|
+
const drainCheck = setInterval(() => {
|
|
37984
|
+
waited += 50;
|
|
37985
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
37986
|
+
clearInterval(drainCheck);
|
|
37987
|
+
cb?.(new Error("WebSocket closed during backpressure wait"));
|
|
37988
|
+
return;
|
|
37989
|
+
}
|
|
37990
|
+
if (ws.bufferedAmount <= _OCPPClient._BACKPRESSURE_THRESHOLD || waited >= 1e4) {
|
|
37991
|
+
clearInterval(drainCheck);
|
|
37992
|
+
ws.send(data, cb);
|
|
37993
|
+
}
|
|
37994
|
+
}, 50);
|
|
37995
|
+
} else {
|
|
37996
|
+
ws.send(data, cb);
|
|
37997
|
+
}
|
|
37998
|
+
}
|
|
37783
37999
|
// ─── Internal: Ping/Pong ─────────────────────────────────────
|
|
37784
38000
|
_startPing() {
|
|
37785
38001
|
if (this._options.pingIntervalMs <= 0) return;
|
|
@@ -37929,6 +38145,52 @@ var OCPPClient = class _OCPPClient extends EventEmitter {
|
|
|
37929
38145
|
}
|
|
37930
38146
|
};
|
|
37931
38147
|
|
|
38148
|
+
// src/lru-map.ts
|
|
38149
|
+
var LRUMap = class extends Map {
|
|
38150
|
+
_maxSize;
|
|
38151
|
+
constructor(maxSize) {
|
|
38152
|
+
super();
|
|
38153
|
+
if (maxSize < 1) throw new RangeError("LRUMap maxSize must be >= 1");
|
|
38154
|
+
this._maxSize = maxSize;
|
|
38155
|
+
}
|
|
38156
|
+
/**
|
|
38157
|
+
* Returns the configured maximum capacity of this LRU cache.
|
|
38158
|
+
*/
|
|
38159
|
+
get maxSize() {
|
|
38160
|
+
return this._maxSize;
|
|
38161
|
+
}
|
|
38162
|
+
/**
|
|
38163
|
+
* Sets a key-value pair. If the key already exists, it is promoted to the
|
|
38164
|
+
* most-recently-used position. If inserting a new key would exceed capacity,
|
|
38165
|
+
* the oldest (least-recently-used) entry is evicted.
|
|
38166
|
+
*/
|
|
38167
|
+
set(key, value) {
|
|
38168
|
+
if (this.has(key)) {
|
|
38169
|
+
this.delete(key);
|
|
38170
|
+
}
|
|
38171
|
+
super.set(key, value);
|
|
38172
|
+
if (this.size > this._maxSize) {
|
|
38173
|
+
const oldest = this.keys().next().value;
|
|
38174
|
+
if (oldest !== void 0) {
|
|
38175
|
+
this.delete(oldest);
|
|
38176
|
+
}
|
|
38177
|
+
}
|
|
38178
|
+
return this;
|
|
38179
|
+
}
|
|
38180
|
+
/**
|
|
38181
|
+
* Gets a value by key and promotes it to the most-recently-used position.
|
|
38182
|
+
* Uses `this.has(key)` instead of a value truthiness check to correctly
|
|
38183
|
+
* handle stored values of `undefined`, `null`, `0`, `""`, etc.
|
|
38184
|
+
*/
|
|
38185
|
+
get(key) {
|
|
38186
|
+
if (!this.has(key)) return void 0;
|
|
38187
|
+
const value = super.get(key);
|
|
38188
|
+
this.delete(key);
|
|
38189
|
+
super.set(key, value);
|
|
38190
|
+
return value;
|
|
38191
|
+
}
|
|
38192
|
+
};
|
|
38193
|
+
|
|
37932
38194
|
// src/router.ts
|
|
37933
38195
|
import { EventEmitter as EventEmitter2 } from "events";
|
|
37934
38196
|
async function executeMiddlewareChain(middlewares, ctx) {
|
|
@@ -38287,6 +38549,7 @@ var OCPPServerClient = class extends OCPPClient {
|
|
|
38287
38549
|
this._ws = context.ws;
|
|
38288
38550
|
this._protocol = context.protocol ?? context.ws.protocol;
|
|
38289
38551
|
this._attachServerWebsocket(context.ws);
|
|
38552
|
+
this._startPing();
|
|
38290
38553
|
}
|
|
38291
38554
|
// ─── Rate Limiting State ──────────────────────────────────────────
|
|
38292
38555
|
_rateLimits = {};
|
|
@@ -38454,9 +38717,11 @@ var OCPPServer = class extends EventEmitter3 {
|
|
|
38454
38717
|
_httpServerAbortControllers = /* @__PURE__ */ new Set();
|
|
38455
38718
|
_logger = null;
|
|
38456
38719
|
_globalCORS;
|
|
38720
|
+
// Connection-level rate limiting (per-IP token bucket)
|
|
38721
|
+
_connectionBuckets = /* @__PURE__ */ new Map();
|
|
38457
38722
|
// Robustness & Clustering
|
|
38458
38723
|
_nodeId = createId2();
|
|
38459
|
-
_sessions
|
|
38724
|
+
_sessions;
|
|
38460
38725
|
_gcInterval = null;
|
|
38461
38726
|
_sessionTimeoutMs;
|
|
38462
38727
|
constructor(options = {}) {
|
|
@@ -38481,6 +38746,8 @@ var OCPPServer = class extends EventEmitter3 {
|
|
|
38481
38746
|
...options
|
|
38482
38747
|
};
|
|
38483
38748
|
this._sessionTimeoutMs = this._options.sessionTtlMs;
|
|
38749
|
+
const maxSessions = this._options.maxSessions ?? 5e4;
|
|
38750
|
+
this._sessions = new LRUMap(maxSessions);
|
|
38484
38751
|
this._wss = new WebSocketServer({ noServer: true });
|
|
38485
38752
|
this._gcInterval = setInterval(() => {
|
|
38486
38753
|
const now = Date.now();
|
|
@@ -38695,6 +38962,69 @@ var OCPPServer = class extends EventEmitter3 {
|
|
|
38695
38962
|
};
|
|
38696
38963
|
httpServer.on("upgrade", upgradeHandler);
|
|
38697
38964
|
this._httpServers.add(httpServer);
|
|
38965
|
+
if (this._options.healthEndpoint) {
|
|
38966
|
+
httpServer.on("request", (req, res) => {
|
|
38967
|
+
const url = req.url ?? "";
|
|
38968
|
+
if (url === "/health") {
|
|
38969
|
+
const s = this.stats();
|
|
38970
|
+
const body = JSON.stringify({
|
|
38971
|
+
status: this._state === "OPEN" ? "ok" : "degraded",
|
|
38972
|
+
state: this._state,
|
|
38973
|
+
connectedClients: s.connectedClients,
|
|
38974
|
+
activeSessions: s.activeSessions,
|
|
38975
|
+
uptimeSeconds: Math.round(s.uptimeSeconds),
|
|
38976
|
+
pid: s.pid
|
|
38977
|
+
});
|
|
38978
|
+
res.writeHead(200, {
|
|
38979
|
+
"Content-Type": "application/json",
|
|
38980
|
+
"Cache-Control": "no-cache"
|
|
38981
|
+
});
|
|
38982
|
+
res.end(body);
|
|
38983
|
+
return;
|
|
38984
|
+
}
|
|
38985
|
+
if (url === "/metrics") {
|
|
38986
|
+
const s = this.stats();
|
|
38987
|
+
const lines = [
|
|
38988
|
+
"# HELP ocpp_connected_clients Number of currently connected OCPP clients",
|
|
38989
|
+
"# TYPE ocpp_connected_clients gauge",
|
|
38990
|
+
`ocpp_connected_clients ${s.connectedClients}`,
|
|
38991
|
+
"",
|
|
38992
|
+
"# HELP ocpp_active_sessions Number of active in-memory sessions",
|
|
38993
|
+
"# TYPE ocpp_active_sessions gauge",
|
|
38994
|
+
`ocpp_active_sessions ${s.activeSessions}`,
|
|
38995
|
+
"",
|
|
38996
|
+
"# HELP ocpp_uptime_seconds Process uptime in seconds",
|
|
38997
|
+
"# TYPE ocpp_uptime_seconds gauge",
|
|
38998
|
+
`ocpp_uptime_seconds ${Math.round(s.uptimeSeconds)}`,
|
|
38999
|
+
"",
|
|
39000
|
+
"# HELP ocpp_memory_rss_bytes Resident set size in bytes",
|
|
39001
|
+
"# TYPE ocpp_memory_rss_bytes gauge",
|
|
39002
|
+
`ocpp_memory_rss_bytes ${s.memoryUsage.rss}`,
|
|
39003
|
+
"",
|
|
39004
|
+
"# HELP ocpp_memory_heap_used_bytes V8 heap used in bytes",
|
|
39005
|
+
"# TYPE ocpp_memory_heap_used_bytes gauge",
|
|
39006
|
+
`ocpp_memory_heap_used_bytes ${s.memoryUsage.heapUsed}`,
|
|
39007
|
+
"",
|
|
39008
|
+
"# HELP ocpp_memory_heap_total_bytes V8 heap total in bytes",
|
|
39009
|
+
"# TYPE ocpp_memory_heap_total_bytes gauge",
|
|
39010
|
+
`ocpp_memory_heap_total_bytes ${s.memoryUsage.heapTotal}`,
|
|
39011
|
+
"",
|
|
39012
|
+
"# HELP ocpp_ws_buffered_bytes Total buffered WebSocket bytes",
|
|
39013
|
+
"# TYPE ocpp_ws_buffered_bytes gauge",
|
|
39014
|
+
`ocpp_ws_buffered_bytes ${s.webSockets?.bufferedAmount ?? 0}`,
|
|
39015
|
+
""
|
|
39016
|
+
];
|
|
39017
|
+
res.writeHead(200, {
|
|
39018
|
+
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
|
39019
|
+
"Cache-Control": "no-cache"
|
|
39020
|
+
});
|
|
39021
|
+
res.end(lines.join("\n"));
|
|
39022
|
+
return;
|
|
39023
|
+
}
|
|
39024
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
39025
|
+
res.end("Not Found");
|
|
39026
|
+
});
|
|
39027
|
+
}
|
|
38698
39028
|
if (options?.signal) {
|
|
38699
39029
|
const ac = new AbortController();
|
|
38700
39030
|
this._httpServerAbortControllers.add(ac);
|
|
@@ -38758,6 +39088,30 @@ var OCPPServer = class extends EventEmitter3 {
|
|
|
38758
39088
|
abortHandshake(socket, 503, "Server is shutting down");
|
|
38759
39089
|
return;
|
|
38760
39090
|
}
|
|
39091
|
+
const connRateLimit = this._options.connectionRateLimit;
|
|
39092
|
+
if (connRateLimit) {
|
|
39093
|
+
const ip = req.socket.remoteAddress ?? "unknown";
|
|
39094
|
+
const now = Date.now();
|
|
39095
|
+
let bucket = this._connectionBuckets.get(ip);
|
|
39096
|
+
if (!bucket) {
|
|
39097
|
+
bucket = { tokens: connRateLimit.limit, lastRefill: now };
|
|
39098
|
+
this._connectionBuckets.set(ip, bucket);
|
|
39099
|
+
} else {
|
|
39100
|
+
const elapsed = now - bucket.lastRefill;
|
|
39101
|
+
const refillRate = connRateLimit.limit / connRateLimit.windowMs;
|
|
39102
|
+
bucket.tokens = Math.min(
|
|
39103
|
+
connRateLimit.limit,
|
|
39104
|
+
bucket.tokens + elapsed * refillRate
|
|
39105
|
+
);
|
|
39106
|
+
bucket.lastRefill = now;
|
|
39107
|
+
}
|
|
39108
|
+
if (bucket.tokens < 1) {
|
|
39109
|
+
this._logger?.warn?.("Connection rate limit exceeded", { ip });
|
|
39110
|
+
abortHandshake(socket, 429, "Too Many Requests");
|
|
39111
|
+
return;
|
|
39112
|
+
}
|
|
39113
|
+
bucket.tokens -= 1;
|
|
39114
|
+
}
|
|
38761
39115
|
if (socket.readyState !== "open") {
|
|
38762
39116
|
this._logger?.debug?.("Socket not open at upgrade start");
|
|
38763
39117
|
if (!socket.destroyed) socket.destroy();
|
|
@@ -39076,6 +39430,20 @@ var OCPPServer = class extends EventEmitter3 {
|
|
|
39076
39430
|
protocol: selectedProtocol
|
|
39077
39431
|
});
|
|
39078
39432
|
this._updateSessionActivity(identity, client.session);
|
|
39433
|
+
const existingClient = this._clientsByIdentity.get(identity);
|
|
39434
|
+
if (existingClient && existingClient !== client) {
|
|
39435
|
+
this._logger?.warn?.("Evicting stale connection for identity", {
|
|
39436
|
+
identity,
|
|
39437
|
+
reason: "Duplicate identity replaced by new connection"
|
|
39438
|
+
});
|
|
39439
|
+
existingClient.close({
|
|
39440
|
+
code: 4e3,
|
|
39441
|
+
reason: "Evicted by new connection",
|
|
39442
|
+
force: true
|
|
39443
|
+
}).catch(() => {
|
|
39444
|
+
});
|
|
39445
|
+
this._clients.delete(existingClient);
|
|
39446
|
+
}
|
|
39079
39447
|
this._clients.add(client);
|
|
39080
39448
|
this._clientsByIdentity.set(identity, client);
|
|
39081
39449
|
if (this._adapter?.setPresence) {
|
|
@@ -39131,6 +39499,29 @@ var OCPPServer = class extends EventEmitter3 {
|
|
|
39131
39499
|
clearInterval(this._gcInterval);
|
|
39132
39500
|
this._gcInterval = null;
|
|
39133
39501
|
}
|
|
39502
|
+
if (!options.force) {
|
|
39503
|
+
const drainTimeout = 5e3;
|
|
39504
|
+
const drainPromises = Array.from(this._clients).map(async (client) => {
|
|
39505
|
+
const ws = client._ws;
|
|
39506
|
+
if (ws && ws.bufferedAmount > 0) {
|
|
39507
|
+
this._logger?.debug?.("Waiting for client buffer to drain", {
|
|
39508
|
+
identity: client.identity,
|
|
39509
|
+
bufferedAmount: ws.bufferedAmount
|
|
39510
|
+
});
|
|
39511
|
+
await new Promise((resolve) => {
|
|
39512
|
+
let elapsed = 0;
|
|
39513
|
+
const check = setInterval(() => {
|
|
39514
|
+
elapsed += 50;
|
|
39515
|
+
if (!ws || ws.bufferedAmount === 0 || elapsed >= drainTimeout) {
|
|
39516
|
+
clearInterval(check);
|
|
39517
|
+
resolve();
|
|
39518
|
+
}
|
|
39519
|
+
}, 50);
|
|
39520
|
+
});
|
|
39521
|
+
}
|
|
39522
|
+
});
|
|
39523
|
+
await Promise.allSettled(drainPromises);
|
|
39524
|
+
}
|
|
39134
39525
|
const closePromises = Array.from(this._clients).map(
|
|
39135
39526
|
(client) => client.close(options).catch(() => {
|
|
39136
39527
|
})
|
|
@@ -39357,6 +39748,7 @@ var OCPPServer = class extends EventEmitter3 {
|
|
|
39357
39748
|
export {
|
|
39358
39749
|
ConnectionState,
|
|
39359
39750
|
InMemoryAdapter,
|
|
39751
|
+
LRUMap,
|
|
39360
39752
|
MessageType,
|
|
39361
39753
|
MiddlewareStack,
|
|
39362
39754
|
NOREPLY,
|