ocpp-ws-io 1.0.0-alpha

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.

Potentially problematic release.


This version of ocpp-ws-io might be problematic. Click here for more details.

Files changed (45) hide show
  1. package/.github/workflows/publish.yml +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +773 -0
  4. package/dist/adapters/redis.d.mts +73 -0
  5. package/dist/adapters/redis.d.ts +73 -0
  6. package/dist/adapters/redis.js +96 -0
  7. package/dist/adapters/redis.js.map +1 -0
  8. package/dist/adapters/redis.mjs +71 -0
  9. package/dist/adapters/redis.mjs.map +1 -0
  10. package/dist/index.d.mts +268 -0
  11. package/dist/index.d.ts +268 -0
  12. package/dist/index.js +38919 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/index.mjs +38855 -0
  15. package/dist/index.mjs.map +1 -0
  16. package/dist/types-6LVUoXof.d.mts +284 -0
  17. package/dist/types-6LVUoXof.d.ts +284 -0
  18. package/package.json +59 -0
  19. package/src/adapters/adapter.ts +40 -0
  20. package/src/adapters/redis.ts +144 -0
  21. package/src/client.ts +882 -0
  22. package/src/errors.ts +183 -0
  23. package/src/event-buffer.ts +73 -0
  24. package/src/index.ts +68 -0
  25. package/src/queue.ts +65 -0
  26. package/src/schemas/ocpp1_6.json +2376 -0
  27. package/src/schemas/ocpp2_0_1.json +11878 -0
  28. package/src/schemas/ocpp2_1.json +23176 -0
  29. package/src/server-client.ts +65 -0
  30. package/src/server.ts +374 -0
  31. package/src/standard-validators.ts +18 -0
  32. package/src/types.ts +316 -0
  33. package/src/util.ts +119 -0
  34. package/src/validator.ts +148 -0
  35. package/src/ws-util.ts +186 -0
  36. package/test/adapter.test.ts +88 -0
  37. package/test/client.test.ts +297 -0
  38. package/test/errors.test.ts +132 -0
  39. package/test/queue.test.ts +133 -0
  40. package/test/server.test.ts +274 -0
  41. package/test/util.test.ts +103 -0
  42. package/test/ws-util.test.ts +93 -0
  43. package/tsconfig.json +25 -0
  44. package/tsup.config.ts +16 -0
  45. package/vitest.config.ts +10 -0
package/src/client.ts ADDED
@@ -0,0 +1,882 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import { setTimeout as setTimeoutCb } from "node:timers";
4
+ import WebSocket from "ws";
5
+
6
+ import {
7
+ ConnectionState,
8
+ SecurityProfile,
9
+ MessageType,
10
+ NOREPLY,
11
+ type ClientOptions,
12
+ type CloseOptions,
13
+ type CallOptions,
14
+ type CallHandler,
15
+ type WildcardHandler,
16
+ type HandlerContext,
17
+ type OCPPCall,
18
+ type OCPPCallResult,
19
+ type OCPPCallError,
20
+ type OCPPMessage,
21
+ } from "./types.js";
22
+ import {
23
+ TimeoutError,
24
+ UnexpectedHttpResponse,
25
+ RPCGenericError,
26
+ RPCMessageTypeNotSupportedError,
27
+ type RPCError,
28
+ } from "./errors.js";
29
+ import {
30
+ createRPCError,
31
+ getErrorPlainObject,
32
+ getPackageIdent,
33
+ } from "./util.js";
34
+ import { Queue } from "./queue.js";
35
+ import type { Validator } from "./validator.js";
36
+ import { standardValidators } from "./standard-validators.js";
37
+
38
+ const { CONNECTING, OPEN, CLOSING, CLOSED } = ConnectionState;
39
+
40
+ interface PendingCall {
41
+ resolve: (value: unknown) => void;
42
+ reject: (reason: unknown) => void;
43
+ timeoutHandle: ReturnType<typeof setTimeoutCb>;
44
+ abortHandler?: () => void;
45
+ }
46
+
47
+ /**
48
+ * OCPPClient — A typed WebSocket RPC client for OCPP communication.
49
+ *
50
+ * Supports all 3 OCPP Security Profiles:
51
+ * - Profile 1: Basic Auth over unsecured WS
52
+ * - Profile 2: TLS + Basic Auth
53
+ * - Profile 3: Mutual TLS (client certificates)
54
+ */
55
+ export class OCPPClient extends EventEmitter {
56
+ // Static connection states
57
+ static readonly CONNECTING = CONNECTING;
58
+ static readonly OPEN = OPEN;
59
+ static readonly CLOSING = CLOSING;
60
+ static readonly CLOSED = CLOSED;
61
+
62
+ protected _options: Required<
63
+ Pick<
64
+ ClientOptions,
65
+ | "identity"
66
+ | "endpoint"
67
+ | "callTimeoutMs"
68
+ | "pingIntervalMs"
69
+ | "deferPingsOnActivity"
70
+ | "callConcurrency"
71
+ | "maxBadMessages"
72
+ | "respondWithDetailedErrors"
73
+ | "reconnect"
74
+ | "maxReconnects"
75
+ | "backoffMin"
76
+ | "backoffMax"
77
+ >
78
+ > &
79
+ ClientOptions;
80
+
81
+ protected _state: ConnectionState = CLOSED;
82
+ protected _ws: WebSocket | null = null;
83
+ protected _protocol: string | undefined;
84
+ protected _identity: string;
85
+
86
+ private _handlers = new Map<string, CallHandler>();
87
+ private _wildcardHandler: WildcardHandler | null = null;
88
+ private _pendingCalls = new Map<string, PendingCall>();
89
+ private _pendingResponses = new Set<string>();
90
+ private _callQueue: Queue;
91
+ private _pingTimer: ReturnType<typeof setTimeoutCb> | null = null;
92
+ private _closePromise: Promise<{ code: number; reason: string }> | null =
93
+ null;
94
+ private _reconnectAttempt = 0;
95
+ private _reconnectTimer: ReturnType<typeof setTimeoutCb> | null = null;
96
+ private _badMessageCount = 0;
97
+ private _lastActivity = 0;
98
+ private _validators: Validator[] = [];
99
+ private _strictProtocols: string[] | null = null;
100
+ protected _handshake: unknown = null;
101
+
102
+ constructor(options: ClientOptions) {
103
+ super();
104
+
105
+ if (!options.identity) {
106
+ throw new Error("identity is required");
107
+ }
108
+
109
+ this._identity = options.identity;
110
+
111
+ this._options = {
112
+ reconnect: true,
113
+ maxReconnects: Infinity,
114
+ backoffMin: 1000,
115
+ backoffMax: 30000,
116
+ callTimeoutMs: 30000,
117
+ pingIntervalMs: 30000,
118
+ deferPingsOnActivity: false,
119
+ callConcurrency: 1,
120
+ maxBadMessages: Infinity,
121
+ respondWithDetailedErrors: false,
122
+ securityProfile: SecurityProfile.NONE,
123
+ ...options,
124
+ };
125
+
126
+ this._callQueue = new Queue(this._options.callConcurrency);
127
+
128
+ // Set up strict mode validators
129
+ if (this._options.strictMode) {
130
+ this._setupValidators();
131
+ }
132
+ }
133
+
134
+ // ─── Getters ─────────────────────────────────────────────────
135
+
136
+ get identity(): string {
137
+ return this._identity;
138
+ }
139
+ get protocol(): string | undefined {
140
+ return this._protocol;
141
+ }
142
+ get state(): ConnectionState {
143
+ return this._state;
144
+ }
145
+ get securityProfile(): SecurityProfile {
146
+ return this._options.securityProfile ?? SecurityProfile.NONE;
147
+ }
148
+
149
+ // ─── Connect ─────────────────────────────────────────────────
150
+
151
+ async connect(): Promise<{ response: import("node:http").IncomingMessage }> {
152
+ if (this._state !== CLOSED) {
153
+ throw new Error(`Cannot connect: client is in state ${this._state}`);
154
+ }
155
+
156
+ this._state = CONNECTING;
157
+ this._reconnectAttempt = 0;
158
+
159
+ return this._connectInternal();
160
+ }
161
+
162
+ private async _connectInternal(): Promise<{
163
+ response: import("node:http").IncomingMessage;
164
+ }> {
165
+ return new Promise((resolve, reject) => {
166
+ const endpoint = this._buildEndpoint();
167
+ const wsOptions = this._buildWsOptions();
168
+
169
+ this.emit("connecting", { url: endpoint });
170
+
171
+ const ws = new WebSocket(
172
+ endpoint,
173
+ this._options.protocols ?? [],
174
+ wsOptions,
175
+ );
176
+ this._ws = ws;
177
+
178
+ const onOpen = () => {
179
+ cleanup();
180
+ this._state = OPEN;
181
+ this._protocol = ws.protocol;
182
+ this._badMessageCount = 0;
183
+ this._attachWebsocket(ws);
184
+ this._startPing();
185
+
186
+ // Create a minimal response object
187
+ const response = (
188
+ ws as unknown as {
189
+ _req?: { res?: import("node:http").IncomingMessage };
190
+ }
191
+ )._req?.res;
192
+ const result = {
193
+ response: response as import("node:http").IncomingMessage,
194
+ };
195
+ this.emit("open", result);
196
+ resolve(result);
197
+ };
198
+
199
+ const onError = (err: Error) => {
200
+ cleanup();
201
+ this._state = CLOSED;
202
+ this.emit("error", err);
203
+ reject(err);
204
+ };
205
+
206
+ const onUnexpectedResponse = (
207
+ _req: import("node:http").ClientRequest,
208
+ res: import("node:http").IncomingMessage,
209
+ ) => {
210
+ cleanup();
211
+ this._state = CLOSED;
212
+ const err = new UnexpectedHttpResponse(
213
+ `Unexpected HTTP response: ${res.statusCode}`,
214
+ res.statusCode ?? 0,
215
+ res.headers as Record<string, string>,
216
+ );
217
+ this.emit("error", err);
218
+ reject(err);
219
+ };
220
+
221
+ const cleanup = () => {
222
+ ws.removeListener("open", onOpen);
223
+ ws.removeListener("error", onError);
224
+ ws.removeListener("unexpected-response", onUnexpectedResponse);
225
+ };
226
+
227
+ ws.on("open", onOpen);
228
+ ws.on("error", onError);
229
+ ws.on("unexpected-response", onUnexpectedResponse);
230
+ });
231
+ }
232
+
233
+ // ─── Close ───────────────────────────────────────────────────
234
+
235
+ async close(
236
+ options: CloseOptions = {},
237
+ ): Promise<{ code: number; reason: string }> {
238
+ const {
239
+ code = 1000,
240
+ reason = "",
241
+ awaitPending = true,
242
+ force = false,
243
+ } = options;
244
+
245
+ if (this._closePromise) return this._closePromise;
246
+
247
+ if (this._state === CLOSED) {
248
+ return { code: 1000, reason: "" };
249
+ }
250
+
251
+ // Cancel reconnection
252
+ if (this._reconnectTimer) {
253
+ clearTimeout(this._reconnectTimer);
254
+ this._reconnectTimer = null;
255
+ }
256
+
257
+ this._closePromise = this._closeInternal(code, reason, awaitPending, force);
258
+ return this._closePromise;
259
+ }
260
+
261
+ private async _closeInternal(
262
+ code: number,
263
+ reason: string,
264
+ awaitPending: boolean,
265
+ force: boolean,
266
+ ): Promise<{ code: number; reason: string }> {
267
+ this._state = CLOSING;
268
+ this._stopPing();
269
+
270
+ if (!force && awaitPending) {
271
+ // Wait for pending calls to resolve
272
+ const pendingPromises = Array.from(this._pendingCalls.values()).map(
273
+ (p) =>
274
+ new Promise<void>((resolve) => {
275
+ const origResolve = p.resolve;
276
+ const origReject = p.reject;
277
+ p.resolve = (v: unknown) => {
278
+ origResolve(v);
279
+ resolve();
280
+ };
281
+ p.reject = (r: unknown) => {
282
+ origReject(r);
283
+ resolve();
284
+ };
285
+ }),
286
+ );
287
+ if (pendingPromises.length > 0) {
288
+ await Promise.allSettled(pendingPromises);
289
+ }
290
+ }
291
+
292
+ return new Promise<{ code: number; reason: string }>((resolve) => {
293
+ if (!this._ws || this._ws.readyState === WebSocket.CLOSED) {
294
+ this._state = CLOSED;
295
+ this._cleanup();
296
+ const result = { code, reason };
297
+ this.emit("close", result);
298
+ resolve(result);
299
+ return;
300
+ }
301
+
302
+ const onClose = (closeCode: number, closeReason: Buffer) => {
303
+ this._ws?.removeListener("close", onClose);
304
+ this._state = CLOSED;
305
+ this._cleanup();
306
+ const result = { code: closeCode, reason: closeReason.toString() };
307
+ this.emit("close", result);
308
+ resolve(result);
309
+ };
310
+
311
+ this._ws.on("close", onClose);
312
+
313
+ if (force) {
314
+ this._ws.terminate();
315
+ } else {
316
+ this._ws.close(code, reason);
317
+ }
318
+ });
319
+ }
320
+
321
+ // ─── Handle ──────────────────────────────────────────────────
322
+
323
+ handle<TParams = unknown, TResult = unknown>(
324
+ methodOrHandler: string | WildcardHandler,
325
+ handler?: CallHandler<TParams, TResult>,
326
+ ): void {
327
+ if (typeof methodOrHandler === "function") {
328
+ this._wildcardHandler = methodOrHandler;
329
+ } else if (typeof methodOrHandler === "string" && handler) {
330
+ this._handlers.set(methodOrHandler, handler as CallHandler);
331
+ } else {
332
+ throw new Error(
333
+ "Invalid arguments: provide (method, handler) or (wildcardHandler)",
334
+ );
335
+ }
336
+ }
337
+
338
+ removeHandler(method?: string): void {
339
+ if (method) {
340
+ this._handlers.delete(method);
341
+ } else {
342
+ this._wildcardHandler = null;
343
+ }
344
+ }
345
+
346
+ removeAllHandlers(): void {
347
+ this._handlers.clear();
348
+ this._wildcardHandler = null;
349
+ }
350
+
351
+ // ─── Call ────────────────────────────────────────────────────
352
+
353
+ async call<TResult = unknown>(
354
+ method: string,
355
+ params: unknown = {},
356
+ options: CallOptions = {},
357
+ ): Promise<TResult> {
358
+ if (this._state !== OPEN) {
359
+ throw new Error(`Cannot call: client is in state ${this._state}`);
360
+ }
361
+
362
+ return this._callQueue.push(() =>
363
+ this._sendCall<TResult>(method, params, options),
364
+ );
365
+ }
366
+
367
+ private async _sendCall<TResult>(
368
+ method: string,
369
+ params: unknown,
370
+ options: CallOptions,
371
+ ): Promise<TResult> {
372
+ const msgId = randomUUID();
373
+ const timeoutMs = options.timeoutMs ?? this._options.callTimeoutMs;
374
+
375
+ // Strict mode: validate outbound call
376
+ if (this._options.strictMode && this._protocol) {
377
+ this._validateOutbound(method, params, "req");
378
+ }
379
+
380
+ const message: OCPPCall = [MessageType.CALL, msgId, method, params];
381
+ const messageStr = JSON.stringify(message);
382
+
383
+ return new Promise<TResult>((resolve, reject) => {
384
+ const timeoutHandle = setTimeoutCb(() => {
385
+ this._pendingCalls.delete(msgId);
386
+ reject(
387
+ new TimeoutError(
388
+ `Call to "${method}" timed out after ${timeoutMs}ms`,
389
+ ),
390
+ );
391
+ }, timeoutMs);
392
+
393
+ const pending: PendingCall = {
394
+ resolve: resolve as (v: unknown) => void,
395
+ reject,
396
+ timeoutHandle,
397
+ };
398
+
399
+ // Abort signal support
400
+ if (options.signal) {
401
+ if (options.signal.aborted) {
402
+ clearTimeout(timeoutHandle);
403
+ reject(options.signal.reason ?? new Error("Aborted"));
404
+ return;
405
+ }
406
+ const abortHandler = () => {
407
+ clearTimeout(timeoutHandle);
408
+ this._pendingCalls.delete(msgId);
409
+ reject(options.signal!.reason ?? new Error("Aborted"));
410
+ };
411
+ options.signal.addEventListener("abort", abortHandler, { once: true });
412
+ pending.abortHandler = abortHandler;
413
+ }
414
+
415
+ this._pendingCalls.set(msgId, pending);
416
+ this._ws!.send(messageStr);
417
+ this.emit("message", message);
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Send a raw string message over the WebSocket (use with caution).
423
+ */
424
+ sendRaw(message: string): void {
425
+ if (this._state !== OPEN || !this._ws) {
426
+ throw new Error("Cannot send: client is not connected");
427
+ }
428
+ this._ws.send(message);
429
+ }
430
+
431
+ // ─── Reconfigure ─────────────────────────────────────────────
432
+
433
+ reconfigure(options: Partial<ClientOptions>): void {
434
+ Object.assign(this._options, options);
435
+
436
+ if (options.callConcurrency !== undefined) {
437
+ this._callQueue.setConcurrency(options.callConcurrency);
438
+ }
439
+
440
+ if (
441
+ options.strictMode !== undefined ||
442
+ options.strictModeValidators !== undefined
443
+ ) {
444
+ this._setupValidators();
445
+ }
446
+
447
+ if (options.pingIntervalMs !== undefined) {
448
+ this._stopPing();
449
+ if (this._state === OPEN) {
450
+ this._startPing();
451
+ }
452
+ }
453
+ }
454
+
455
+ // ─── Internal: WebSocket attachment ──────────────────────────
456
+
457
+ protected _attachWebsocket(ws: WebSocket): void {
458
+ ws.on("message", (data: WebSocket.RawData) => this._onMessage(data));
459
+ ws.on("close", (code: number, reason: Buffer) =>
460
+ this._onClose(code, reason),
461
+ );
462
+ ws.on("error", (err: Error) => this.emit("error", err));
463
+ ws.on("ping", () => {
464
+ this._recordActivity();
465
+ this.emit("ping");
466
+ });
467
+ ws.on("pong", () => {
468
+ this._recordActivity();
469
+ this.emit("pong");
470
+ });
471
+ }
472
+
473
+ // ─── Internal: Message handling ──────────────────────────────
474
+
475
+ private _onMessage(rawData: WebSocket.RawData): void {
476
+ this._recordActivity();
477
+
478
+ let message: OCPPMessage;
479
+ try {
480
+ const str = rawData.toString();
481
+ message = JSON.parse(str) as OCPPMessage;
482
+ if (!Array.isArray(message)) throw new Error("Message is not an array");
483
+ } catch (err) {
484
+ this._onBadMessage(rawData.toString(), err as Error);
485
+ return;
486
+ }
487
+
488
+ const messageType = message[0];
489
+
490
+ switch (messageType) {
491
+ case MessageType.CALL:
492
+ this._handleIncomingCall(message as OCPPCall);
493
+ break;
494
+ case MessageType.CALLRESULT:
495
+ this._handleCallResult(message as OCPPCallResult);
496
+ break;
497
+ case MessageType.CALLERROR:
498
+ this._handleCallError(message as OCPPCallError);
499
+ break;
500
+ default:
501
+ this._onBadMessage(
502
+ JSON.stringify(message),
503
+ new RPCMessageTypeNotSupportedError(
504
+ `Unknown message type: ${messageType}`,
505
+ ),
506
+ );
507
+ }
508
+ }
509
+
510
+ private async _handleIncomingCall(message: OCPPCall): Promise<void> {
511
+ const [, msgId, method, params] = message;
512
+
513
+ this.emit("call", message);
514
+
515
+ if (this._state !== OPEN) {
516
+ return;
517
+ }
518
+
519
+ try {
520
+ if (this._pendingResponses.has(msgId)) {
521
+ throw createRPCError(
522
+ "RpcFrameworkError",
523
+ `Already processing call with ID: ${msgId}`,
524
+ );
525
+ }
526
+
527
+ let handler = this._handlers.get(method);
528
+ let isWildcard = false;
529
+ if (!handler) {
530
+ if (this._wildcardHandler) {
531
+ isWildcard = true;
532
+ } else {
533
+ throw createRPCError(
534
+ "NotImplemented",
535
+ `No handler for method: ${method}`,
536
+ );
537
+ }
538
+ }
539
+
540
+ // Strict mode: validate inbound call params
541
+ if (this._options.strictMode && this._protocol) {
542
+ this._validateInbound(method, params, "req");
543
+ }
544
+
545
+ this._pendingResponses.add(msgId);
546
+
547
+ const ac = new AbortController();
548
+ const context: HandlerContext = {
549
+ messageId: msgId,
550
+ method,
551
+ params,
552
+ signal: ac.signal,
553
+ };
554
+
555
+ let result: unknown;
556
+ if (isWildcard && this._wildcardHandler) {
557
+ result = await this._wildcardHandler(method, context);
558
+ } else {
559
+ result = await handler!(context);
560
+ }
561
+
562
+ this._pendingResponses.delete(msgId);
563
+
564
+ if (result === NOREPLY) return;
565
+
566
+ // Strict mode: validate outbound response
567
+ if (this._options.strictMode && this._protocol) {
568
+ this._validateOutbound(method, result, "conf");
569
+ }
570
+
571
+ const response: OCPPCallResult = [MessageType.CALLRESULT, msgId, result];
572
+ this._ws?.send(JSON.stringify(response));
573
+ this.emit("callResult", response);
574
+ } catch (err) {
575
+ this._pendingResponses.delete(msgId);
576
+
577
+ const rpcErr =
578
+ err instanceof RPCGenericError || (err as RPCError).rpcErrorCode
579
+ ? (err as RPCError)
580
+ : createRPCError("InternalError", (err as Error).message);
581
+
582
+ const details = this._options.respondWithDetailedErrors
583
+ ? getErrorPlainObject(err as Error)
584
+ : {};
585
+
586
+ const errorResponse: OCPPCallError = [
587
+ MessageType.CALLERROR,
588
+ msgId,
589
+ rpcErr.rpcErrorCode,
590
+ rpcErr.rpcErrorMessage || (err as Error).message || "",
591
+ details,
592
+ ];
593
+ this._ws?.send(JSON.stringify(errorResponse));
594
+ this.emit("callError", errorResponse);
595
+ }
596
+ }
597
+
598
+ private _handleCallResult(message: OCPPCallResult): void {
599
+ const [, msgId, result] = message;
600
+
601
+ this.emit("callResult", message);
602
+
603
+ const pending = this._pendingCalls.get(msgId);
604
+ if (!pending) return;
605
+
606
+ clearTimeout(pending.timeoutHandle);
607
+ if (pending.abortHandler) {
608
+ // Remove abort listener
609
+ }
610
+ this._pendingCalls.delete(msgId);
611
+ pending.resolve(result);
612
+ }
613
+
614
+ private _handleCallError(message: OCPPCallError): void {
615
+ const [, msgId, errorCode, errorMessage, errorDetails] = message;
616
+
617
+ this.emit("callError", message);
618
+
619
+ const pending = this._pendingCalls.get(msgId);
620
+ if (!pending) return;
621
+
622
+ clearTimeout(pending.timeoutHandle);
623
+ this._pendingCalls.delete(msgId);
624
+
625
+ const err = createRPCError(errorCode, errorMessage, errorDetails);
626
+ pending.reject(err);
627
+ }
628
+
629
+ // ─── Internal: Bad message handling ──────────────────────────
630
+
631
+ private _onBadMessage(rawMessage: string, error: Error): void {
632
+ this._badMessageCount++;
633
+ this.emit("badMessage", { message: rawMessage, error });
634
+
635
+ if (this._badMessageCount >= this._options.maxBadMessages) {
636
+ this.close({ code: 1002, reason: "Too many bad messages" }).catch(
637
+ () => {},
638
+ );
639
+ }
640
+ }
641
+
642
+ // ─── Internal: Close handling ────────────────────────────────
643
+
644
+ private _onClose(code: number, reason: Buffer): void {
645
+ this._stopPing();
646
+ const reasonStr = reason.toString();
647
+
648
+ // Reject all pending calls
649
+ for (const [, pending] of this._pendingCalls) {
650
+ clearTimeout(pending.timeoutHandle);
651
+ pending.reject(new Error(`Connection closed (${code}: ${reasonStr})`));
652
+ }
653
+ this._pendingCalls.clear();
654
+ this._pendingResponses.clear();
655
+
656
+ if (this._state !== CLOSING) {
657
+ // Unexpected close — attempt reconnect
658
+ this._state = CLOSED;
659
+ this.emit("close", { code, reason: reasonStr });
660
+
661
+ if (
662
+ this._options.reconnect &&
663
+ this._reconnectAttempt < this._options.maxReconnects
664
+ ) {
665
+ this._scheduleReconnect();
666
+ }
667
+ } else {
668
+ this._state = CLOSED;
669
+ // close() handles the emit
670
+ }
671
+ }
672
+
673
+ // ─── Internal: Reconnection ──────────────────────────────────
674
+
675
+ private _scheduleReconnect(): void {
676
+ this._reconnectAttempt++;
677
+
678
+ // Exponential backoff with jitter
679
+ const base = this._options.backoffMin;
680
+ const max = this._options.backoffMax;
681
+ const delayMs = Math.min(
682
+ max,
683
+ base *
684
+ Math.pow(2, this._reconnectAttempt - 1) *
685
+ (0.5 + Math.random() * 0.5),
686
+ );
687
+
688
+ this.emit("reconnect", { attempt: this._reconnectAttempt, delay: delayMs });
689
+
690
+ this._reconnectTimer = setTimeoutCb(async () => {
691
+ this._reconnectTimer = null;
692
+ try {
693
+ this._state = CLOSED; // Reset for connect
694
+ await this._connectInternal();
695
+ } catch {
696
+ if (
697
+ this._reconnectAttempt < this._options.maxReconnects &&
698
+ this._options.reconnect
699
+ ) {
700
+ this._scheduleReconnect();
701
+ }
702
+ }
703
+ }, delayMs);
704
+ }
705
+
706
+ // ─── Internal: Ping/Pong ─────────────────────────────────────
707
+
708
+ private _startPing(): void {
709
+ if (this._options.pingIntervalMs <= 0) return;
710
+
711
+ const doPing = () => {
712
+ if (this._state !== OPEN || !this._ws) return;
713
+
714
+ if (this._options.deferPingsOnActivity) {
715
+ const elapsed = Date.now() - this._lastActivity;
716
+ if (elapsed < this._options.pingIntervalMs) {
717
+ this._pingTimer = setTimeoutCb(
718
+ doPing,
719
+ this._options.pingIntervalMs - elapsed,
720
+ );
721
+ return;
722
+ }
723
+ }
724
+
725
+ this._ws.ping();
726
+ this._pingTimer = setTimeoutCb(doPing, this._options.pingIntervalMs);
727
+ };
728
+
729
+ this._pingTimer = setTimeoutCb(doPing, this._options.pingIntervalMs);
730
+ }
731
+
732
+ private _stopPing(): void {
733
+ if (this._pingTimer) {
734
+ clearTimeout(this._pingTimer);
735
+ this._pingTimer = null;
736
+ }
737
+ }
738
+
739
+ private _recordActivity(): void {
740
+ this._lastActivity = Date.now();
741
+ }
742
+
743
+ // ─── Internal: Validation ────────────────────────────────────
744
+
745
+ private _setupValidators(): void {
746
+ if (this._options.strictModeValidators) {
747
+ this._validators = this._options.strictModeValidators;
748
+ } else {
749
+ this._validators = standardValidators;
750
+ }
751
+
752
+ if (Array.isArray(this._options.strictMode)) {
753
+ this._strictProtocols = this._options.strictMode;
754
+ } else {
755
+ this._strictProtocols = null;
756
+ }
757
+ }
758
+
759
+ private _validateOutbound(
760
+ method: string,
761
+ params: unknown,
762
+ suffix: "req" | "conf",
763
+ ): void {
764
+ const validator = this._findValidator();
765
+ if (!validator) return;
766
+
767
+ const schemaId = `urn:${method}.${suffix}`;
768
+ try {
769
+ validator.validate(schemaId, params);
770
+ } catch (err) {
771
+ this.emit("strictValidationFailure", {
772
+ message: params,
773
+ error: err as Error,
774
+ });
775
+ throw err;
776
+ }
777
+ }
778
+
779
+ private _validateInbound(
780
+ method: string,
781
+ params: unknown,
782
+ suffix: "req" | "conf",
783
+ ): void {
784
+ const validator = this._findValidator();
785
+ if (!validator) return;
786
+
787
+ const schemaId = `urn:${method}.${suffix}`;
788
+ try {
789
+ validator.validate(schemaId, params);
790
+ } catch (err) {
791
+ this.emit("strictValidationFailure", {
792
+ message: params,
793
+ error: err as Error,
794
+ });
795
+ throw err;
796
+ }
797
+ }
798
+
799
+ private _findValidator(): Validator | null {
800
+ if (!this._protocol) return null;
801
+
802
+ if (
803
+ this._strictProtocols &&
804
+ !this._strictProtocols.includes(this._protocol)
805
+ ) {
806
+ return null;
807
+ }
808
+
809
+ return (
810
+ this._validators.find((v) => v.subprotocol === this._protocol) ?? null
811
+ );
812
+ }
813
+
814
+ // ─── Internal: Endpoint building ─────────────────────────────
815
+
816
+ private _buildEndpoint(): string {
817
+ let url = this._options.endpoint;
818
+
819
+ // Append identity to URL path
820
+ if (!url.endsWith("/")) url += "/";
821
+ url += encodeURIComponent(this._identity);
822
+
823
+ // Append query parameters
824
+ if (this._options.query) {
825
+ const params = new URLSearchParams(this._options.query);
826
+ url += (url.includes("?") ? "&" : "?") + params.toString();
827
+ }
828
+
829
+ return url;
830
+ }
831
+
832
+ private _buildWsOptions(): WebSocket.ClientOptions {
833
+ const opts: WebSocket.ClientOptions = {
834
+ headers: {
835
+ ...this._options.headers,
836
+ "User-Agent": getPackageIdent(),
837
+ },
838
+ };
839
+
840
+ const profile = this._options.securityProfile ?? SecurityProfile.NONE;
841
+
842
+ // Profile 1 & 2: Basic Auth header
843
+ if (
844
+ (profile === SecurityProfile.BASIC_AUTH ||
845
+ profile === SecurityProfile.TLS_BASIC_AUTH) &&
846
+ this._options.password
847
+ ) {
848
+ const credentials = Buffer.from(
849
+ `${this._identity}:${this._options.password.toString()}`,
850
+ ).toString("base64");
851
+ opts.headers!["Authorization"] = `Basic ${credentials}`;
852
+ }
853
+
854
+ // Profile 2 & 3: TLS options
855
+ if (
856
+ profile === SecurityProfile.TLS_BASIC_AUTH ||
857
+ profile === SecurityProfile.TLS_CLIENT_CERT
858
+ ) {
859
+ const tls = this._options.tls ?? {};
860
+ if (tls.ca) opts.ca = tls.ca;
861
+ if (tls.rejectUnauthorized !== undefined)
862
+ opts.rejectUnauthorized = tls.rejectUnauthorized;
863
+
864
+ // Profile 3: Client certificates for mTLS
865
+ if (profile === SecurityProfile.TLS_CLIENT_CERT) {
866
+ if (tls.cert) opts.cert = tls.cert;
867
+ if (tls.key) opts.key = tls.key;
868
+ if (tls.passphrase) opts.passphrase = tls.passphrase;
869
+ }
870
+ }
871
+
872
+ return opts;
873
+ }
874
+
875
+ // ─── Internal: Cleanup ───────────────────────────────────────
876
+
877
+ private _cleanup(): void {
878
+ this._stopPing();
879
+ this._closePromise = null;
880
+ this._ws = null;
881
+ }
882
+ }