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
@@ -0,0 +1,65 @@
1
+ import { WebSocket } from "ws";
2
+ import { OCPPClient } from "./client.js";
3
+ import {
4
+ ConnectionState,
5
+ type ClientOptions,
6
+ type HandshakeInfo,
7
+ } from "./types.js";
8
+
9
+ /**
10
+ * OCPPServerClient — A server-side client representation.
11
+ *
12
+ * Created by OCPPServer when a charging station connects.
13
+ * Extends OCPPClient but is pre-connected (cannot call connect()).
14
+ */
15
+ export class OCPPServerClient extends OCPPClient {
16
+ private _serverSession: Record<string, unknown>;
17
+ private _serverHandshake: HandshakeInfo;
18
+
19
+ constructor(
20
+ options: ClientOptions,
21
+ context: {
22
+ ws: WebSocket;
23
+ handshake: HandshakeInfo;
24
+ session: Record<string, unknown>;
25
+ },
26
+ ) {
27
+ super(options);
28
+
29
+ this._serverSession = context.session;
30
+ this._serverHandshake = context.handshake;
31
+
32
+ // Set state to OPEN directly (already connected via server)
33
+ this._state = ConnectionState.OPEN;
34
+ this._identity = this._options.identity;
35
+ this._ws = context.ws;
36
+ this._protocol = context.ws.protocol;
37
+
38
+ // Attach WebSocket handlers
39
+ this._attachWebsocket(context.ws);
40
+ }
41
+
42
+ /**
43
+ * Session data associated with this client connection.
44
+ */
45
+ get session(): Record<string, unknown> {
46
+ return this._serverSession;
47
+ }
48
+
49
+ /**
50
+ * Handshake information from the initial connection.
51
+ */
52
+ get handshake(): HandshakeInfo {
53
+ return this._serverHandshake;
54
+ }
55
+
56
+ /**
57
+ * Server clients cannot initiate connections.
58
+ * @throws Always throws — use OCPPClient for outbound connections.
59
+ */
60
+ override async connect(): Promise<never> {
61
+ throw new Error(
62
+ "Cannot connect from server client — connection is managed by the server",
63
+ );
64
+ }
65
+ }
package/src/server.ts ADDED
@@ -0,0 +1,374 @@
1
+ import { EventEmitter } from "node:events";
2
+ import {
3
+ createServer as createHttpServer,
4
+ type IncomingMessage,
5
+ type Server,
6
+ } from "node:http";
7
+ import { createServer as createHttpsServer } from "node:https";
8
+ import type { Duplex } from "node:stream";
9
+ import type { TLSSocket } from "node:tls";
10
+ import { WebSocketServer } from "ws";
11
+
12
+ import { OCPPServerClient } from "./server-client.js";
13
+ import { abortHandshake, parseSubprotocols } from "./ws-util.js";
14
+
15
+ import {
16
+ SecurityProfile,
17
+ type ServerOptions,
18
+ type CloseOptions,
19
+ type ListenOptions,
20
+ type HandshakeInfo,
21
+ type AuthCallback,
22
+ type AuthAccept,
23
+ type EventAdapterInterface,
24
+ type ClientOptions,
25
+ } from "./types.js";
26
+
27
+ /**
28
+ * OCPPServer — A typed WebSocket RPC server for OCPP communication.
29
+ *
30
+ * Supports all 3 OCPP Security Profiles:
31
+ * - Profile 1: Basic Auth over unsecured WS
32
+ * - Profile 2: TLS + Basic Auth (HTTPS server)
33
+ * - Profile 3: Mutual TLS (HTTPS server with requestCert)
34
+ */
35
+ export class OCPPServer extends EventEmitter {
36
+ private _options: ServerOptions;
37
+ private _authCallback: AuthCallback | null = null;
38
+ private _clients = new Set<OCPPServerClient>();
39
+ private _httpServers = new Set<Server>();
40
+ private _wss: WebSocketServer | null = null;
41
+ private _adapter: EventAdapterInterface | null = null;
42
+ private _httpServerAbortControllers = new Set<AbortController>();
43
+
44
+ constructor(options: ServerOptions = {}) {
45
+ super();
46
+
47
+ if (options.strictMode) {
48
+ if (!options.strictModeValidators && !options.protocols?.length) {
49
+ throw new Error(
50
+ "strictMode requires either strictModeValidators or protocols to be specified",
51
+ );
52
+ }
53
+ }
54
+
55
+ this._options = {
56
+ securityProfile: SecurityProfile.NONE,
57
+ callTimeoutMs: 30000,
58
+ pingIntervalMs: 30000,
59
+ deferPingsOnActivity: false,
60
+ callConcurrency: 1,
61
+ maxBadMessages: Infinity,
62
+ respondWithDetailedErrors: false,
63
+ ...options,
64
+ };
65
+ }
66
+
67
+ // ─── Getters ─────────────────────────────────────────────────
68
+
69
+ get clients(): ReadonlySet<OCPPServerClient> {
70
+ return this._clients;
71
+ }
72
+
73
+ // ─── Auth ────────────────────────────────────────────────────
74
+
75
+ auth(callback: AuthCallback): void {
76
+ this._authCallback = callback;
77
+ }
78
+
79
+ // ─── Listen ──────────────────────────────────────────────────
80
+
81
+ async listen(
82
+ port = 0,
83
+ host?: string,
84
+ options?: ListenOptions,
85
+ ): Promise<Server> {
86
+ let httpServer: Server;
87
+
88
+ if (options?.server) {
89
+ // Use existing HTTP/HTTPS server
90
+ httpServer = options.server;
91
+ } else {
92
+ // Create server based on security profile
93
+ const profile = this._options.securityProfile ?? SecurityProfile.NONE;
94
+
95
+ if (
96
+ profile === SecurityProfile.TLS_BASIC_AUTH ||
97
+ profile === SecurityProfile.TLS_CLIENT_CERT
98
+ ) {
99
+ const tlsOpts = this._options.tls ?? {};
100
+ const httpsOptions: Record<string, unknown> = {};
101
+
102
+ if (tlsOpts.cert) httpsOptions.cert = tlsOpts.cert;
103
+ if (tlsOpts.key) httpsOptions.key = tlsOpts.key;
104
+ if (tlsOpts.ca) httpsOptions.ca = tlsOpts.ca;
105
+ if (tlsOpts.passphrase) httpsOptions.passphrase = tlsOpts.passphrase;
106
+
107
+ // Profile 3: Request client certificate (mTLS)
108
+ if (profile === SecurityProfile.TLS_CLIENT_CERT) {
109
+ httpsOptions.requestCert = true;
110
+ httpsOptions.rejectUnauthorized = tlsOpts.rejectUnauthorized ?? true;
111
+ }
112
+
113
+ httpServer = createHttpsServer(httpsOptions);
114
+ } else {
115
+ httpServer = createHttpServer();
116
+ }
117
+ }
118
+
119
+ // Create WebSocketServer attached to noServer mode
120
+ if (!this._wss) {
121
+ this._wss = new WebSocketServer({ noServer: true });
122
+ }
123
+
124
+ // Handle upgrade requests
125
+ const upgradeHandler = (
126
+ req: IncomingMessage,
127
+ socket: Duplex,
128
+ head: Buffer,
129
+ ) => {
130
+ this._handleUpgrade(req, socket, head).catch((err) => {
131
+ this.emit("upgradeError", { error: err, socket });
132
+ });
133
+ };
134
+
135
+ httpServer.on("upgrade", upgradeHandler);
136
+ this._httpServers.add(httpServer);
137
+
138
+ // Handle abort signal
139
+ if (options?.signal) {
140
+ const ac = new AbortController();
141
+ this._httpServerAbortControllers.add(ac);
142
+
143
+ options.signal.addEventListener(
144
+ "abort",
145
+ () => {
146
+ ac.abort();
147
+ httpServer.close();
148
+ this._httpServers.delete(httpServer);
149
+ },
150
+ { once: true },
151
+ );
152
+ }
153
+
154
+ // Start listening if we created the server
155
+ if (!options?.server) {
156
+ await new Promise<void>((resolve, reject) => {
157
+ httpServer.on("error", reject);
158
+ httpServer.listen(port, host, () => {
159
+ httpServer.removeListener("error", reject);
160
+ resolve();
161
+ });
162
+ });
163
+ }
164
+
165
+ return httpServer;
166
+ }
167
+
168
+ // ─── Handle Upgrade ──────────────────────────────────────────
169
+
170
+ get handleUpgrade(): (
171
+ req: IncomingMessage,
172
+ socket: Duplex,
173
+ head: Buffer,
174
+ ) => Promise<void> {
175
+ return (req, socket, head) => this._handleUpgrade(req, socket, head);
176
+ }
177
+
178
+ private async _handleUpgrade(
179
+ req: IncomingMessage,
180
+ socket: Duplex,
181
+ head: Buffer,
182
+ ): Promise<void> {
183
+ const url = new URL(
184
+ req.url ?? "/",
185
+ `http://${req.headers.host ?? "localhost"}`,
186
+ );
187
+ const pathParts = url.pathname.split("/").filter(Boolean);
188
+ const identity = decodeURIComponent(pathParts[pathParts.length - 1] ?? "");
189
+
190
+ if (!identity) {
191
+ abortHandshake(socket, 400, "Missing identity in URL path");
192
+ return;
193
+ }
194
+
195
+ // Parse subprotocols
196
+ let protocols = new Set<string>();
197
+ const protocolHeader = req.headers["sec-websocket-protocol"];
198
+ if (protocolHeader) {
199
+ try {
200
+ protocols = parseSubprotocols(protocolHeader);
201
+ } catch {
202
+ abortHandshake(socket, 400, "Invalid Sec-WebSocket-Protocol header");
203
+ return;
204
+ }
205
+ }
206
+
207
+ // Negotiate protocol
208
+ const serverProtocols = this._options.protocols ?? [];
209
+ let selectedProtocol: string | undefined;
210
+
211
+ if (serverProtocols.length > 0 && protocols.size > 0) {
212
+ selectedProtocol = serverProtocols.find((p) => protocols.has(p));
213
+ if (!selectedProtocol) {
214
+ abortHandshake(socket, 400, "No matching subprotocol");
215
+ return;
216
+ }
217
+ }
218
+
219
+ // Parse Basic Auth
220
+ let password: Buffer | undefined;
221
+ const authHeader = req.headers["authorization"];
222
+ if (authHeader && authHeader.startsWith("Basic ")) {
223
+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
224
+ const colonIndex = decoded.indexOf(":");
225
+ if (colonIndex !== -1) {
226
+ password = Buffer.from(decoded.slice(colonIndex + 1));
227
+ }
228
+ }
229
+
230
+ // Get client certificate (Profile 3)
231
+ let clientCertificate:
232
+ | ReturnType<TLSSocket["getPeerCertificate"]>
233
+ | undefined;
234
+ const profile = this._options.securityProfile ?? SecurityProfile.NONE;
235
+ if (
236
+ profile === SecurityProfile.TLS_CLIENT_CERT &&
237
+ "getPeerCertificate" in socket
238
+ ) {
239
+ clientCertificate = (socket as TLSSocket).getPeerCertificate();
240
+ }
241
+
242
+ const handshake: HandshakeInfo = {
243
+ identity,
244
+ remoteAddress: req.socket.remoteAddress ?? "",
245
+ headers: req.headers as Record<string, string | string[] | undefined>,
246
+ protocols,
247
+ endpoint: url.pathname,
248
+ query: url.searchParams,
249
+ request: req,
250
+ password,
251
+ clientCertificate,
252
+ securityProfile: profile,
253
+ };
254
+
255
+ // Auth callback
256
+ if (this._authCallback) {
257
+ const ac = new AbortController();
258
+
259
+ try {
260
+ await new Promise<AuthAccept | void>((resolve, reject) => {
261
+ let settled = false;
262
+
263
+ const accept = (opts?: AuthAccept) => {
264
+ if (settled) throw new Error("Auth already settled");
265
+ settled = true;
266
+ if (opts?.protocol) selectedProtocol = opts.protocol;
267
+ resolve(opts);
268
+ };
269
+
270
+ const rejectAuth = (code = 401, message = "Unauthorized") => {
271
+ if (settled) throw new Error("Auth already settled");
272
+ settled = true;
273
+ reject({ code, message });
274
+ };
275
+
276
+ this._authCallback!(accept, rejectAuth, handshake, ac.signal);
277
+ });
278
+ } catch (err) {
279
+ const { code, message } = err as { code: number; message: string };
280
+ abortHandshake(socket, code ?? 401, message ?? "Unauthorized");
281
+ return;
282
+ }
283
+ }
284
+
285
+ // Complete WebSocket upgrade
286
+ if (!this._wss) return;
287
+
288
+ this._wss.handleUpgrade(req, socket, head, (ws) => {
289
+ // Build options for the server-side client
290
+ const clientOptions: ClientOptions = {
291
+ identity,
292
+ endpoint: "",
293
+ callTimeoutMs: this._options.callTimeoutMs,
294
+ pingIntervalMs: this._options.pingIntervalMs,
295
+ deferPingsOnActivity: this._options.deferPingsOnActivity,
296
+ callConcurrency: this._options.callConcurrency,
297
+ maxBadMessages: this._options.maxBadMessages,
298
+ respondWithDetailedErrors: this._options.respondWithDetailedErrors,
299
+ strictMode: this._options.strictMode,
300
+ strictModeValidators: this._options.strictModeValidators,
301
+ reconnect: false,
302
+ };
303
+
304
+ const client = new OCPPServerClient(clientOptions, {
305
+ ws,
306
+ handshake,
307
+ session: {},
308
+ });
309
+
310
+ this._clients.add(client);
311
+
312
+ client.on("close", () => {
313
+ this._clients.delete(client);
314
+ });
315
+
316
+ this.emit("client", client);
317
+ });
318
+ }
319
+
320
+ // ─── Close ───────────────────────────────────────────────────
321
+
322
+ async close(options: CloseOptions = {}): Promise<void> {
323
+ // Close all clients
324
+ const closePromises = Array.from(this._clients).map((client) =>
325
+ client.close(options).catch(() => {}),
326
+ );
327
+ await Promise.allSettled(closePromises);
328
+
329
+ // Abort all controllers
330
+ for (const ac of this._httpServerAbortControllers) {
331
+ ac.abort();
332
+ }
333
+ this._httpServerAbortControllers.clear();
334
+
335
+ // Close WebSocket server
336
+ if (this._wss) {
337
+ this._wss.close();
338
+ this._wss = null;
339
+ }
340
+
341
+ // Close all HTTP servers
342
+ const serverClosePromises = Array.from(this._httpServers).map(
343
+ (server) =>
344
+ new Promise<void>((resolve) => {
345
+ server.close(() => resolve());
346
+ }),
347
+ );
348
+ await Promise.allSettled(serverClosePromises);
349
+ this._httpServers.clear();
350
+
351
+ // Disconnect adapter
352
+ if (this._adapter) {
353
+ await this._adapter.disconnect();
354
+ }
355
+ }
356
+
357
+ // ─── Reconfigure ─────────────────────────────────────────────
358
+
359
+ reconfigure(options: Partial<ServerOptions>): void {
360
+ Object.assign(this._options, options);
361
+ }
362
+
363
+ // ─── Pub/Sub Adapter ─────────────────────────────────────────
364
+
365
+ setAdapter(adapter: EventAdapterInterface): void {
366
+ this._adapter = adapter;
367
+ }
368
+
369
+ async publish(channel: string, data: unknown): Promise<void> {
370
+ if (this._adapter) {
371
+ await this._adapter.publish(channel, data);
372
+ }
373
+ }
374
+ }
@@ -0,0 +1,18 @@
1
+ import {
2
+ createValidator,
3
+ type Validator,
4
+ type ValidatorSchema,
5
+ } from "./validator.js";
6
+ import ocpp16 from "./schemas/ocpp1_6.json";
7
+ import ocpp201 from "./schemas/ocpp2_0_1.json";
8
+ import ocpp21 from "./schemas/ocpp2_1.json";
9
+
10
+ /**
11
+ * Pre-built validators for all supported OCPP protocol versions.
12
+ * These are automatically registered when strict mode is enabled.
13
+ */
14
+ export const standardValidators: Validator[] = [
15
+ createValidator("ocpp1.6", ocpp16 as ValidatorSchema[]),
16
+ createValidator("ocpp2.0.1", ocpp201 as ValidatorSchema[]),
17
+ createValidator("ocpp2.1", ocpp21 as ValidatorSchema[]),
18
+ ];