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/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 +21 -0
- package/dist/browser.d.ts +21 -0
- package/dist/browser.js.map +1 -1
- package/dist/browser.mjs.map +1 -1
- package/dist/{index-BixJj_yJ.d.mts → index-1QBeqAuc.d.mts} +145 -4
- package/dist/{index-BixJj_yJ.d.ts → index-1QBeqAuc.d.ts} +145 -4
- package/dist/index.d.mts +64 -8
- package/dist/index.d.ts +64 -8
- package/dist/index.js +502 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +500 -18
- 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
|
+
// 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
|
-
|
|
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
|
|
36841
|
-
|
|
36842
|
-
|
|
36843
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
39879
|
+
getStandardValidators
|
|
39398
39880
|
};
|
|
39399
39881
|
//# sourceMappingURL=index.mjs.map
|