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.
- package/.github/workflows/publish.yml +52 -0
- package/LICENSE +21 -0
- package/README.md +773 -0
- package/dist/adapters/redis.d.mts +73 -0
- package/dist/adapters/redis.d.ts +73 -0
- package/dist/adapters/redis.js +96 -0
- package/dist/adapters/redis.js.map +1 -0
- package/dist/adapters/redis.mjs +71 -0
- package/dist/adapters/redis.mjs.map +1 -0
- package/dist/index.d.mts +268 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.js +38919 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +38855 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types-6LVUoXof.d.mts +284 -0
- package/dist/types-6LVUoXof.d.ts +284 -0
- package/package.json +59 -0
- package/src/adapters/adapter.ts +40 -0
- package/src/adapters/redis.ts +144 -0
- package/src/client.ts +882 -0
- package/src/errors.ts +183 -0
- package/src/event-buffer.ts +73 -0
- package/src/index.ts +68 -0
- package/src/queue.ts +65 -0
- package/src/schemas/ocpp1_6.json +2376 -0
- package/src/schemas/ocpp2_0_1.json +11878 -0
- package/src/schemas/ocpp2_1.json +23176 -0
- package/src/server-client.ts +65 -0
- package/src/server.ts +374 -0
- package/src/standard-validators.ts +18 -0
- package/src/types.ts +316 -0
- package/src/util.ts +119 -0
- package/src/validator.ts +148 -0
- package/src/ws-util.ts +186 -0
- package/test/adapter.test.ts +88 -0
- package/test/client.test.ts +297 -0
- package/test/errors.test.ts +132 -0
- package/test/queue.test.ts +133 -0
- package/test/server.test.ts +274 -0
- package/test/util.test.ts +103 -0
- package/test/ws-util.test.ts +93 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +16 -0
- package/vitest.config.ts +10 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import type { TLSSocket } from "node:tls";
|
|
3
|
+
import type { Duplex } from "node:stream";
|
|
4
|
+
import type { Validator } from "./validator.js";
|
|
5
|
+
|
|
6
|
+
// ─── OCPP Protocol ───────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export type OCPPProtocol = "ocpp1.6" | "ocpp2.0.1" | "ocpp2.1";
|
|
9
|
+
|
|
10
|
+
// ─── Connection State ────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export const ConnectionState = {
|
|
13
|
+
CONNECTING: 0,
|
|
14
|
+
OPEN: 1,
|
|
15
|
+
CLOSING: 2,
|
|
16
|
+
CLOSED: 3,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export type ConnectionState =
|
|
20
|
+
(typeof ConnectionState)[keyof typeof ConnectionState];
|
|
21
|
+
|
|
22
|
+
// ─── Security Profiles (OCPP Spec) ──────────────────────────────
|
|
23
|
+
|
|
24
|
+
export enum SecurityProfile {
|
|
25
|
+
/** No security — plain WS, no auth (dev/testing only) */
|
|
26
|
+
NONE = 0,
|
|
27
|
+
/** Profile 1: Basic Auth over unsecured WS (ws://) — password-based */
|
|
28
|
+
BASIC_AUTH = 1,
|
|
29
|
+
/** Profile 2: TLS + Basic Auth (wss://) — server cert + password */
|
|
30
|
+
TLS_BASIC_AUTH = 2,
|
|
31
|
+
/** Profile 3: Mutual TLS (wss://) — client + server certificates */
|
|
32
|
+
TLS_CLIENT_CERT = 3,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Message Types ───────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export const MessageType = {
|
|
38
|
+
CALL: 2,
|
|
39
|
+
CALLRESULT: 3,
|
|
40
|
+
CALLERROR: 4,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type MessageType = (typeof MessageType)[keyof typeof MessageType];
|
|
44
|
+
|
|
45
|
+
// ─── OCPP Message Tuples ─────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export type OCPPCall<T = unknown> = [2, string, string, T];
|
|
48
|
+
export type OCPPCallResult<T = unknown> = [3, string, T];
|
|
49
|
+
export type OCPPCallError = [
|
|
50
|
+
4,
|
|
51
|
+
string,
|
|
52
|
+
string,
|
|
53
|
+
string,
|
|
54
|
+
Record<string, unknown>,
|
|
55
|
+
];
|
|
56
|
+
export type OCPPMessage<T = unknown> =
|
|
57
|
+
| OCPPCall<T>
|
|
58
|
+
| OCPPCallResult<T>
|
|
59
|
+
| OCPPCallError;
|
|
60
|
+
|
|
61
|
+
// ─── TLS Options ─────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface TLSOptions {
|
|
64
|
+
/** Server/client certificate (PEM) */
|
|
65
|
+
cert?: string | Buffer;
|
|
66
|
+
/** Private key (PEM) */
|
|
67
|
+
key?: string | Buffer;
|
|
68
|
+
/** CA certificate(s) for verification */
|
|
69
|
+
ca?: string | Buffer | Array<string | Buffer>;
|
|
70
|
+
/** Reject unauthorized certs (default: true) */
|
|
71
|
+
rejectUnauthorized?: boolean;
|
|
72
|
+
/** Passphrase for encrypted private key */
|
|
73
|
+
passphrase?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Handler Types ───────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export interface HandlerContext<T = unknown> {
|
|
79
|
+
messageId: string;
|
|
80
|
+
method: string;
|
|
81
|
+
params: T;
|
|
82
|
+
signal: AbortSignal;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type CallHandler<TParams = unknown, TResult = unknown> = (
|
|
86
|
+
context: HandlerContext<TParams>,
|
|
87
|
+
) => TResult | Promise<TResult>;
|
|
88
|
+
|
|
89
|
+
export type WildcardHandler = (
|
|
90
|
+
method: string,
|
|
91
|
+
context: HandlerContext,
|
|
92
|
+
) => unknown | Promise<unknown>;
|
|
93
|
+
|
|
94
|
+
// ─── Call Options ────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export interface CallOptions {
|
|
97
|
+
/** Timeout in milliseconds for this specific call */
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
/** Abort signal */
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
/** Suppress sending a response (server-side, NOREPLY) */
|
|
102
|
+
noReply?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Close Options ───────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export interface CloseOptions {
|
|
108
|
+
/** WebSocket close code (default: 1000) */
|
|
109
|
+
code?: number;
|
|
110
|
+
/** Close reason string */
|
|
111
|
+
reason?: string;
|
|
112
|
+
/** Wait for pending calls to complete before closing */
|
|
113
|
+
awaitPending?: boolean;
|
|
114
|
+
/** Force-close without waiting */
|
|
115
|
+
force?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Handshake Info ──────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export interface HandshakeInfo {
|
|
121
|
+
/** Charging station identity (from URL path) */
|
|
122
|
+
identity: string;
|
|
123
|
+
/** Remote IP address */
|
|
124
|
+
remoteAddress: string;
|
|
125
|
+
/** Request headers */
|
|
126
|
+
headers: Record<string, string | string[] | undefined>;
|
|
127
|
+
/** Negotiated subprotocols */
|
|
128
|
+
protocols: Set<string>;
|
|
129
|
+
/** Request endpoint URL path */
|
|
130
|
+
endpoint: string;
|
|
131
|
+
/** URL query parameters */
|
|
132
|
+
query: URLSearchParams;
|
|
133
|
+
/** Original HTTP request */
|
|
134
|
+
request: IncomingMessage;
|
|
135
|
+
/** Password from Basic Auth (Profile 1 & 2) */
|
|
136
|
+
password?: Buffer;
|
|
137
|
+
/** Client certificate (Profile 3 — mTLS) */
|
|
138
|
+
clientCertificate?: ReturnType<TLSSocket["getPeerCertificate"]>;
|
|
139
|
+
/** Active security profile */
|
|
140
|
+
securityProfile: SecurityProfile;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Session Data ────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export type SessionData<T = Record<string, unknown>> = T;
|
|
146
|
+
|
|
147
|
+
// ─── Client Options ──────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export interface ClientOptions {
|
|
150
|
+
/** Unique identity for this client (charging station ID) */
|
|
151
|
+
identity: string;
|
|
152
|
+
/** WebSocket endpoint URL (ws:// or wss://) */
|
|
153
|
+
endpoint: string;
|
|
154
|
+
/** OCPP Security Profile (default: NONE) */
|
|
155
|
+
securityProfile?: SecurityProfile;
|
|
156
|
+
/** Password for Basic Auth (Profile 1 & 2) */
|
|
157
|
+
password?: string | Buffer;
|
|
158
|
+
/** TLS options (Profile 2 & 3) */
|
|
159
|
+
tls?: TLSOptions;
|
|
160
|
+
/** OCPP subprotocols to negotiate */
|
|
161
|
+
protocols?: string[];
|
|
162
|
+
/** Additional WebSocket headers */
|
|
163
|
+
headers?: Record<string, string>;
|
|
164
|
+
/** Additional query parameters */
|
|
165
|
+
query?: Record<string, string>;
|
|
166
|
+
/** Enable automatic reconnection (default: true) */
|
|
167
|
+
reconnect?: boolean;
|
|
168
|
+
/** Maximum reconnection attempts (default: Infinity) */
|
|
169
|
+
maxReconnects?: number;
|
|
170
|
+
/** Back-off base delay in ms (default: 1000) */
|
|
171
|
+
backoffMin?: number;
|
|
172
|
+
/** Back-off max delay in ms (default: 30000) */
|
|
173
|
+
backoffMax?: number;
|
|
174
|
+
/** Call timeout in ms (default: 30000) */
|
|
175
|
+
callTimeoutMs?: number;
|
|
176
|
+
/** Ping interval in ms (default: 30000, 0 to disable) */
|
|
177
|
+
pingIntervalMs?: number;
|
|
178
|
+
/** Defer pings if activity detected (default: false) */
|
|
179
|
+
deferPingsOnActivity?: boolean;
|
|
180
|
+
/** Maximum concurrent outbound calls (default: 1) */
|
|
181
|
+
callConcurrency?: number;
|
|
182
|
+
/** Enable strict mode validation (default: false) */
|
|
183
|
+
strictMode?: boolean | string[];
|
|
184
|
+
/** Custom validators for strict mode */
|
|
185
|
+
strictModeValidators?: Validator[];
|
|
186
|
+
/** Max number of bad messages before closing (default: Infinity) */
|
|
187
|
+
maxBadMessages?: number;
|
|
188
|
+
/** Include error details in responses (default: false) */
|
|
189
|
+
respondWithDetailedErrors?: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Server Options ──────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export interface ServerOptions {
|
|
195
|
+
/** OCPP Security Profile (default: NONE) */
|
|
196
|
+
securityProfile?: SecurityProfile;
|
|
197
|
+
/** TLS options for HTTPS server (Profile 2 & 3) */
|
|
198
|
+
tls?: TLSOptions;
|
|
199
|
+
/** Accepted OCPP subprotocols */
|
|
200
|
+
protocols?: string[];
|
|
201
|
+
/** Call timeout in ms — inherited by server clients (default: 30000) */
|
|
202
|
+
callTimeoutMs?: number;
|
|
203
|
+
/** Ping interval in ms — inherited by server clients (default: 30000) */
|
|
204
|
+
pingIntervalMs?: number;
|
|
205
|
+
/** Defer pings if activity detected — inherited (default: false) */
|
|
206
|
+
deferPingsOnActivity?: boolean;
|
|
207
|
+
/** Max concurrent outbound calls — inherited (default: 1) */
|
|
208
|
+
callConcurrency?: number;
|
|
209
|
+
/** Enable strict mode — inherited (default: false) */
|
|
210
|
+
strictMode?: boolean | string[];
|
|
211
|
+
/** Custom validators — inherited */
|
|
212
|
+
strictModeValidators?: Validator[];
|
|
213
|
+
/** Max bad messages — inherited (default: Infinity) */
|
|
214
|
+
maxBadMessages?: number;
|
|
215
|
+
/** Include error details in responses — inherited (default: false) */
|
|
216
|
+
respondWithDetailedErrors?: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Listen Options ──────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
export interface ListenOptions {
|
|
222
|
+
/** Existing HTTP/HTTPS server to attach to */
|
|
223
|
+
server?: import("node:http").Server | import("node:https").Server;
|
|
224
|
+
/** Hostname to bind to */
|
|
225
|
+
host?: string;
|
|
226
|
+
/** Signal to abort the listen */
|
|
227
|
+
signal?: AbortSignal;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Auth Callback ───────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
export interface AuthAccept<TSession = Record<string, unknown>> {
|
|
233
|
+
/** Subprotocol to use for this client */
|
|
234
|
+
protocol?: string;
|
|
235
|
+
/** Session data attached to the client */
|
|
236
|
+
session?: TSession;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type AuthCallback<TSession = Record<string, unknown>> = (
|
|
240
|
+
accept: (options?: AuthAccept<TSession>) => void,
|
|
241
|
+
reject: (code?: number, message?: string) => void,
|
|
242
|
+
handshake: HandshakeInfo,
|
|
243
|
+
signal: AbortSignal,
|
|
244
|
+
) => void | Promise<void>;
|
|
245
|
+
|
|
246
|
+
// ─── Event Types ─────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
export interface ClientEvents {
|
|
249
|
+
open: [{ response: IncomingMessage }];
|
|
250
|
+
close: [{ code: number; reason: string }];
|
|
251
|
+
error: [Error];
|
|
252
|
+
connecting: [{ url: string }];
|
|
253
|
+
reconnect: [{ attempt: number; delay: number }];
|
|
254
|
+
message: [OCPPMessage];
|
|
255
|
+
call: [OCPPCall];
|
|
256
|
+
callResult: [OCPPCallResult];
|
|
257
|
+
callError: [OCPPCallError];
|
|
258
|
+
badMessage: [{ message: string; error: Error }];
|
|
259
|
+
ping: [];
|
|
260
|
+
pong: [];
|
|
261
|
+
strictValidationFailure: [{ message: unknown; error: Error }];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ServerEvents<TSession = Record<string, unknown>> {
|
|
265
|
+
client: [ServerClientInstance<TSession>];
|
|
266
|
+
error: [Error];
|
|
267
|
+
upgradeError: [{ error: Error; socket: Duplex }];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Forward reference for ServerClient (resolved at runtime)
|
|
271
|
+
export type ServerClientInstance<TSession = Record<string, unknown>> = {
|
|
272
|
+
readonly identity: string;
|
|
273
|
+
readonly protocol: string | undefined;
|
|
274
|
+
readonly session: TSession;
|
|
275
|
+
readonly handshake: HandshakeInfo;
|
|
276
|
+
readonly state: ConnectionState;
|
|
277
|
+
close(options?: CloseOptions): Promise<{ code: number; reason: string }>;
|
|
278
|
+
handle<TParams, TResult>(
|
|
279
|
+
method: string,
|
|
280
|
+
handler: CallHandler<TParams, TResult>,
|
|
281
|
+
): void;
|
|
282
|
+
handle(handler: WildcardHandler): void;
|
|
283
|
+
call<TResult>(
|
|
284
|
+
method: string,
|
|
285
|
+
params?: unknown,
|
|
286
|
+
options?: CallOptions,
|
|
287
|
+
): Promise<TResult>;
|
|
288
|
+
removeHandler(method?: string): void;
|
|
289
|
+
removeAllHandlers(): void;
|
|
290
|
+
reconfigure(options: Partial<ClientOptions>): void;
|
|
291
|
+
on<K extends keyof ClientEvents>(
|
|
292
|
+
event: K,
|
|
293
|
+
listener: (...args: ClientEvents[K]) => void,
|
|
294
|
+
): void;
|
|
295
|
+
once<K extends keyof ClientEvents>(
|
|
296
|
+
event: K,
|
|
297
|
+
listener: (...args: ClientEvents[K]) => void,
|
|
298
|
+
): void;
|
|
299
|
+
off<K extends keyof ClientEvents>(
|
|
300
|
+
event: K,
|
|
301
|
+
listener: (...args: ClientEvents[K]) => void,
|
|
302
|
+
): void;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ─── Event Adapter Interface ─────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
export interface EventAdapterInterface {
|
|
308
|
+
publish(channel: string, data: unknown): Promise<void>;
|
|
309
|
+
subscribe(channel: string, handler: (data: unknown) => void): Promise<void>;
|
|
310
|
+
unsubscribe(channel: string): Promise<void>;
|
|
311
|
+
disconnect(): Promise<void>;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── Symbols ─────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
export const NOREPLY: unique symbol = Symbol("NOREPLY");
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as errors from "./errors.js";
|
|
2
|
+
import type { RPCError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
// ─── RPC Error Factory ──────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Registry mapping OCPP-J RPC error code strings to their corresponding
|
|
8
|
+
* error constructors. Organized by OCPP spec error category.
|
|
9
|
+
*/
|
|
10
|
+
const RPC_ERROR_REGISTRY = new Map<
|
|
11
|
+
string,
|
|
12
|
+
new (message?: string, details?: Record<string, unknown>) => RPCError
|
|
13
|
+
>([
|
|
14
|
+
// Generic / framework errors
|
|
15
|
+
["GenericError", errors.RPCGenericError],
|
|
16
|
+
["RpcFrameworkError", errors.RPCFrameworkError],
|
|
17
|
+
["MessageTypeNotSupported", errors.RPCMessageTypeNotSupportedError],
|
|
18
|
+
|
|
19
|
+
// Action-level errors
|
|
20
|
+
["NotImplemented", errors.RPCNotImplementedError],
|
|
21
|
+
["NotSupported", errors.RPCNotSupportedError],
|
|
22
|
+
["InternalError", errors.RPCInternalError],
|
|
23
|
+
|
|
24
|
+
// Protocol / security errors
|
|
25
|
+
["ProtocolError", errors.RPCProtocolError],
|
|
26
|
+
["SecurityError", errors.RPCSecurityError],
|
|
27
|
+
|
|
28
|
+
// Payload validation errors
|
|
29
|
+
["FormatViolation", errors.RPCFormatViolationError],
|
|
30
|
+
["FormationViolation", errors.RPCFormationViolationError],
|
|
31
|
+
["PropertyConstraintViolation", errors.RPCPropertyConstraintViolationError],
|
|
32
|
+
[
|
|
33
|
+
"OccurrenceConstraintViolation",
|
|
34
|
+
errors.RPCOccurrenceConstraintViolationError,
|
|
35
|
+
],
|
|
36
|
+
["TypeConstraintViolation", errors.RPCTypeConstraintViolationError],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Instantiate a typed RPCError from a string error code.
|
|
41
|
+
* Returns an RPCGenericError if the code is not recognized.
|
|
42
|
+
*/
|
|
43
|
+
export function createRPCError(
|
|
44
|
+
code: string,
|
|
45
|
+
message?: string,
|
|
46
|
+
details: Record<string, unknown> = {},
|
|
47
|
+
): RPCError {
|
|
48
|
+
const Ctor = RPC_ERROR_REGISTRY.get(code) ?? errors.RPCGenericError;
|
|
49
|
+
return new Ctor(message, details);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Error Serialization ────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Known error properties to extract, in a defined order.
|
|
56
|
+
* This covers standard Error fields plus common OCPP RPC fields.
|
|
57
|
+
*/
|
|
58
|
+
const ERROR_PROPERTIES = [
|
|
59
|
+
"name",
|
|
60
|
+
"message",
|
|
61
|
+
"stack",
|
|
62
|
+
"code",
|
|
63
|
+
"rpcErrorCode",
|
|
64
|
+
"rpcErrorMessage",
|
|
65
|
+
"details",
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert an Error (or subclass) into a plain, JSON-safe object.
|
|
70
|
+
*
|
|
71
|
+
* Extracts well-known properties explicitly rather than relying on
|
|
72
|
+
* Object.getOwnPropertyNames to avoid exposing internal fields and
|
|
73
|
+
* to guarantee a stable output shape.
|
|
74
|
+
*
|
|
75
|
+
* If a property holds a non-serializable value (functions, symbols,
|
|
76
|
+
* circular references), it is silently skipped.
|
|
77
|
+
*/
|
|
78
|
+
export function getErrorPlainObject(err: Error): Record<string, unknown> {
|
|
79
|
+
const result: Record<string, unknown> = {};
|
|
80
|
+
|
|
81
|
+
for (const prop of ERROR_PROPERTIES) {
|
|
82
|
+
const value = (err as unknown as Record<string, unknown>)[prop];
|
|
83
|
+
if (value !== undefined) {
|
|
84
|
+
// Skip functions and symbols — they aren't JSON-serializable
|
|
85
|
+
if (typeof value === "function" || typeof value === "symbol") continue;
|
|
86
|
+
|
|
87
|
+
// Test serializability for complex values
|
|
88
|
+
if (typeof value === "object" && value !== null) {
|
|
89
|
+
try {
|
|
90
|
+
JSON.stringify(value);
|
|
91
|
+
result[prop] = value;
|
|
92
|
+
} catch {
|
|
93
|
+
// Skip non-serializable properties (circular refs, etc.)
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
result[prop] = value;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Ensure we always have at least name and message
|
|
102
|
+
if (!result.name) result.name = err.name;
|
|
103
|
+
if (!result.message) result.message = err.message;
|
|
104
|
+
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Package Identity ───────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const PKG_NAME = "ocpp-ws-io";
|
|
111
|
+
const PKG_VERSION = "1.0.0";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the package identifier string used in HTTP headers and logging.
|
|
115
|
+
* Format: `ocpp-ws-io/1.0.0`
|
|
116
|
+
*/
|
|
117
|
+
export function getPackageIdent(): string {
|
|
118
|
+
return `${PKG_NAME}/${PKG_VERSION}`;
|
|
119
|
+
}
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import Ajv, { type ValidateFunction } from "ajv";
|
|
2
|
+
import addFormats from "ajv-formats";
|
|
3
|
+
import { createRPCError } from "./util.js";
|
|
4
|
+
|
|
5
|
+
// ─── Validation Error Mapping ───────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maps AJV validation keywords to OCPP-J RPC error codes.
|
|
9
|
+
*
|
|
10
|
+
* Rather than listing each keyword individually, this is organized
|
|
11
|
+
* by the OCPP error category that best describes the validation failure.
|
|
12
|
+
* The mapping is derived from the OCPP-J specification sections on error codes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Keywords indicating the data type itself is wrong */
|
|
16
|
+
const TYPE_VIOLATIONS = new Set(["type"]);
|
|
17
|
+
|
|
18
|
+
/** Keywords indicating a value falls outside allowed bounds or format */
|
|
19
|
+
const FORMAT_VIOLATIONS = new Set([
|
|
20
|
+
"maximum",
|
|
21
|
+
"minimum",
|
|
22
|
+
"maxLength",
|
|
23
|
+
"minLength",
|
|
24
|
+
"pattern",
|
|
25
|
+
"format",
|
|
26
|
+
"anyOf",
|
|
27
|
+
"oneOf",
|
|
28
|
+
"not",
|
|
29
|
+
"if",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** Keywords indicating cardinality / presence constraints are broken */
|
|
33
|
+
const OCCURRENCE_VIOLATIONS = new Set([
|
|
34
|
+
"required",
|
|
35
|
+
"maxItems",
|
|
36
|
+
"minItems",
|
|
37
|
+
"maxProperties",
|
|
38
|
+
"minProperties",
|
|
39
|
+
"additionalProperties",
|
|
40
|
+
"additionalItems",
|
|
41
|
+
"exclusiveMaximum",
|
|
42
|
+
"exclusiveMinimum",
|
|
43
|
+
"multipleOf",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Keywords indicating a property value is not in the allowed set */
|
|
47
|
+
const PROPERTY_VIOLATIONS = new Set(["enum", "const"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve an AJV keyword to the appropriate OCPP RPC error code.
|
|
51
|
+
*/
|
|
52
|
+
function keywordToOCPPError(keyword: string): string {
|
|
53
|
+
if (TYPE_VIOLATIONS.has(keyword)) return "TypeConstraintViolation";
|
|
54
|
+
if (OCCURRENCE_VIOLATIONS.has(keyword))
|
|
55
|
+
return "OccurrenceConstraintViolation";
|
|
56
|
+
if (PROPERTY_VIOLATIONS.has(keyword)) return "PropertyConstraintViolation";
|
|
57
|
+
if (FORMAT_VIOLATIONS.has(keyword)) return "FormatViolation";
|
|
58
|
+
// Fallback for any unknown keywords
|
|
59
|
+
return "FormatViolation";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Validator Class ────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export interface ValidatorSchema {
|
|
65
|
+
$schema?: string;
|
|
66
|
+
$id?: string;
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Schema validator using AJV for OCPP message validation.
|
|
72
|
+
* Each validator is bound to a specific subprotocol version.
|
|
73
|
+
*/
|
|
74
|
+
export class Validator {
|
|
75
|
+
readonly subprotocol: string;
|
|
76
|
+
/** @internal */
|
|
77
|
+
_ajv: Ajv;
|
|
78
|
+
|
|
79
|
+
constructor(subprotocol: string, schemas: ValidatorSchema[]) {
|
|
80
|
+
this.subprotocol = subprotocol;
|
|
81
|
+
this._ajv = new Ajv({
|
|
82
|
+
allErrors: true,
|
|
83
|
+
strict: false,
|
|
84
|
+
multipleOfPrecision: 4,
|
|
85
|
+
});
|
|
86
|
+
addFormats(this._ajv);
|
|
87
|
+
|
|
88
|
+
// OCPP schemas use non-standard URN $id values like "urn:Authorize.req"
|
|
89
|
+
// that fast-uri can't serialize. Normalize $id to simple path-based form.
|
|
90
|
+
for (const schema of schemas) {
|
|
91
|
+
const normalized = { ...schema };
|
|
92
|
+
if (normalized.$id && normalized.$id.startsWith("urn:")) {
|
|
93
|
+
normalized.$id = normalized.$id.replace("urn:", "urn/");
|
|
94
|
+
}
|
|
95
|
+
this._ajv.addSchema(normalized);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalize a schema ID from OCPP URN format to internal path format.
|
|
101
|
+
*/
|
|
102
|
+
private _normalizeSchemaId(schemaId: string): string {
|
|
103
|
+
return schemaId.startsWith("urn:")
|
|
104
|
+
? schemaId.replace("urn:", "urn/")
|
|
105
|
+
: schemaId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate a payload against a schema identified by its $id.
|
|
110
|
+
* Throws a typed RPCError if validation fails.
|
|
111
|
+
*/
|
|
112
|
+
validate(schemaId: string, params: unknown): void {
|
|
113
|
+
const resolvedId = this._normalizeSchemaId(schemaId);
|
|
114
|
+
const validateFn: ValidateFunction | undefined =
|
|
115
|
+
this._ajv.getSchema(resolvedId);
|
|
116
|
+
|
|
117
|
+
if (!validateFn) {
|
|
118
|
+
// Schema not found — skip validation (not all actions have schemas)
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const isValid = validateFn(params);
|
|
123
|
+
if (!isValid && validateFn.errors && validateFn.errors.length > 0) {
|
|
124
|
+
const primaryError = validateFn.errors[0];
|
|
125
|
+
const ocppErrorCode = keywordToOCPPError(primaryError.keyword);
|
|
126
|
+
const description = this._ajv.errorsText(validateFn.errors);
|
|
127
|
+
|
|
128
|
+
throw createRPCError(ocppErrorCode, description);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if a schema exists for the given $id.
|
|
134
|
+
*/
|
|
135
|
+
hasSchema(schemaId: string): boolean {
|
|
136
|
+
return !!this._ajv.getSchema(this._normalizeSchemaId(schemaId));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a validator for a specific subprotocol version.
|
|
142
|
+
*/
|
|
143
|
+
export function createValidator(
|
|
144
|
+
subprotocol: string,
|
|
145
|
+
schemas: ValidatorSchema[],
|
|
146
|
+
): Validator {
|
|
147
|
+
return new Validator(subprotocol, schemas);
|
|
148
|
+
}
|