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/ws-util.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket utility functions for OCPP handshake handling.
|
|
3
|
+
*
|
|
4
|
+
* Subprotocol parsing follows RFC 6455 Section 4.1 and
|
|
5
|
+
* HTTP token rules from RFC 7230 Section 3.2.6.
|
|
6
|
+
* Close code validation per RFC 6455 Section 7.4.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import type { Duplex } from "node:stream";
|
|
11
|
+
|
|
12
|
+
// ─── Subprotocol Parsing ────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Determine if a character code is a valid HTTP token character.
|
|
16
|
+
* RFC 7230 Section 3.2.6: tchar = "!" / "#" / "$" / "%" / "&" / "'" /
|
|
17
|
+
* "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
|
|
18
|
+
*/
|
|
19
|
+
function isTChar(c: number): boolean {
|
|
20
|
+
// ALPHA (A-Z, a-z)
|
|
21
|
+
if ((c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a)) return true;
|
|
22
|
+
// DIGIT (0-9)
|
|
23
|
+
if (c >= 0x30 && c <= 0x39) return true;
|
|
24
|
+
// Special tchar symbols
|
|
25
|
+
switch (c) {
|
|
26
|
+
case 0x21: // !
|
|
27
|
+
case 0x23: // #
|
|
28
|
+
case 0x24: // $
|
|
29
|
+
case 0x25: // %
|
|
30
|
+
case 0x26: // &
|
|
31
|
+
case 0x27: // '
|
|
32
|
+
case 0x2a: // *
|
|
33
|
+
case 0x2b: // +
|
|
34
|
+
case 0x2d: // -
|
|
35
|
+
case 0x2e: // .
|
|
36
|
+
case 0x5e: // ^
|
|
37
|
+
case 0x5f: // _
|
|
38
|
+
case 0x60: // `
|
|
39
|
+
case 0x7c: // |
|
|
40
|
+
case 0x7e: // ~
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse the `Sec-WebSocket-Protocol` header into a Set of protocol names.
|
|
48
|
+
*
|
|
49
|
+
* Implements RFC 6455 Section 4.2.1 grammar for the header value:
|
|
50
|
+
* protocol-list = 1#token
|
|
51
|
+
* (see RFC 7230 Section 7 for the #rule list extension)
|
|
52
|
+
*
|
|
53
|
+
* Whitespace (SP/HTAB) is allowed around commas as per HTTP list rules.
|
|
54
|
+
* Duplicate protocol names and invalid token characters cause a SyntaxError.
|
|
55
|
+
*/
|
|
56
|
+
export function parseSubprotocols(header: string): Set<string> {
|
|
57
|
+
if (header.length === 0) {
|
|
58
|
+
throw new SyntaxError("Unexpected end of input");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const protocols = new Set<string>();
|
|
62
|
+
let cursor = 0;
|
|
63
|
+
|
|
64
|
+
while (cursor < header.length) {
|
|
65
|
+
// Skip leading whitespace before token
|
|
66
|
+
while (
|
|
67
|
+
cursor < header.length &&
|
|
68
|
+
(header.charCodeAt(cursor) === 0x20 || header.charCodeAt(cursor) === 0x09)
|
|
69
|
+
) {
|
|
70
|
+
cursor++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Expect at least one token character
|
|
74
|
+
const tokenStart = cursor;
|
|
75
|
+
while (cursor < header.length && isTChar(header.charCodeAt(cursor))) {
|
|
76
|
+
cursor++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (cursor === tokenStart) {
|
|
80
|
+
throw new SyntaxError(`Unexpected character at index ${cursor}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const token = header.substring(tokenStart, cursor);
|
|
84
|
+
|
|
85
|
+
if (protocols.has(token)) {
|
|
86
|
+
throw new SyntaxError(`The "${token}" subprotocol is duplicated`);
|
|
87
|
+
}
|
|
88
|
+
protocols.add(token);
|
|
89
|
+
|
|
90
|
+
// Skip trailing whitespace after token
|
|
91
|
+
while (
|
|
92
|
+
cursor < header.length &&
|
|
93
|
+
(header.charCodeAt(cursor) === 0x20 || header.charCodeAt(cursor) === 0x09)
|
|
94
|
+
) {
|
|
95
|
+
cursor++;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Expect end of string or comma separator
|
|
99
|
+
if (cursor >= header.length) break;
|
|
100
|
+
|
|
101
|
+
if (header.charCodeAt(cursor) !== 0x2c /* , */) {
|
|
102
|
+
throw new SyntaxError(`Unexpected character at index ${cursor}`);
|
|
103
|
+
}
|
|
104
|
+
cursor++; // consume comma
|
|
105
|
+
|
|
106
|
+
// After a comma, there must be another token — trailing comma is invalid
|
|
107
|
+
// (We'll check at the start of the next iteration)
|
|
108
|
+
// Peek ahead: if only whitespace remains, it's a trailing comma
|
|
109
|
+
let peek = cursor;
|
|
110
|
+
while (
|
|
111
|
+
peek < header.length &&
|
|
112
|
+
(header.charCodeAt(peek) === 0x20 || header.charCodeAt(peek) === 0x09)
|
|
113
|
+
) {
|
|
114
|
+
peek++;
|
|
115
|
+
}
|
|
116
|
+
if (peek >= header.length || !isTChar(header.charCodeAt(peek))) {
|
|
117
|
+
throw new SyntaxError("Unexpected end of input");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Ensure we actually got at least one protocol
|
|
122
|
+
if (protocols.size === 0) {
|
|
123
|
+
throw new SyntaxError("Unexpected end of input");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return protocols;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Close Code Validation ──────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reserved close codes that MUST NOT be set in a Close frame.
|
|
133
|
+
* Per RFC 6455 Section 7.4.1.
|
|
134
|
+
*/
|
|
135
|
+
const RESERVED_CLOSE_CODES = new Set([1004, 1005, 1006]);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if a WebSocket close status code is valid for use in a Close frame.
|
|
139
|
+
*
|
|
140
|
+
* Per RFC 6455 Section 7.4:
|
|
141
|
+
* - 1000–1014 are valid (except 1004, 1005, 1006 which are reserved)
|
|
142
|
+
* - 3000–4999 are available for application/library/framework use
|
|
143
|
+
*/
|
|
144
|
+
export function isValidStatusCode(code: number): boolean {
|
|
145
|
+
if (code >= 1000 && code <= 1014 && !RESERVED_CLOSE_CODES.has(code)) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (code >= 3000 && code <= 4999) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Handshake Abort ────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reject a WebSocket upgrade by sending an HTTP error response
|
|
158
|
+
* to the raw socket and closing the connection.
|
|
159
|
+
*/
|
|
160
|
+
export function abortHandshake(
|
|
161
|
+
socket: Duplex,
|
|
162
|
+
statusCode: number,
|
|
163
|
+
reason?: string,
|
|
164
|
+
extraHeaders?: Record<string, string>,
|
|
165
|
+
): void {
|
|
166
|
+
if (!socket.writable) return;
|
|
167
|
+
|
|
168
|
+
const statusText = http.STATUS_CODES[statusCode] ?? "Unknown";
|
|
169
|
+
const body = reason ?? statusText;
|
|
170
|
+
const bodyBytes = Buffer.byteLength(body, "utf8");
|
|
171
|
+
|
|
172
|
+
const allHeaders: Record<string, string | number> = {
|
|
173
|
+
Connection: "close",
|
|
174
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
175
|
+
"Content-Length": bodyBytes,
|
|
176
|
+
...extraHeaders,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const headerBlock = Object.entries(allHeaders)
|
|
180
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
181
|
+
.join("\r\n");
|
|
182
|
+
|
|
183
|
+
socket.end(
|
|
184
|
+
`HTTP/1.1 ${statusCode} ${statusText}\r\n${headerBlock}\r\n\r\n${body}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { InMemoryAdapter } from "../src/adapters/adapter.js";
|
|
3
|
+
|
|
4
|
+
describe("InMemoryAdapter", () => {
|
|
5
|
+
it("should publish and receive messages", async () => {
|
|
6
|
+
const adapter = new InMemoryAdapter();
|
|
7
|
+
let received: unknown;
|
|
8
|
+
|
|
9
|
+
await adapter.subscribe("test", (data) => {
|
|
10
|
+
received = data;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
await adapter.publish("test", { hello: "world" });
|
|
14
|
+
expect(received).toEqual({ hello: "world" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should support multiple subscribers", async () => {
|
|
18
|
+
const adapter = new InMemoryAdapter();
|
|
19
|
+
const received: unknown[] = [];
|
|
20
|
+
|
|
21
|
+
await adapter.subscribe("ch", (data) => received.push(`a:${data}`));
|
|
22
|
+
await adapter.subscribe("ch", (data) => received.push(`b:${data}`));
|
|
23
|
+
|
|
24
|
+
await adapter.publish("ch", "msg1");
|
|
25
|
+
expect(received).toEqual(["a:msg1", "b:msg1"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should not deliver to unrelated channels", async () => {
|
|
29
|
+
const adapter = new InMemoryAdapter();
|
|
30
|
+
let received = false;
|
|
31
|
+
|
|
32
|
+
await adapter.subscribe("channelA", () => {
|
|
33
|
+
received = true;
|
|
34
|
+
});
|
|
35
|
+
await adapter.publish("channelB", "data");
|
|
36
|
+
|
|
37
|
+
expect(received).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should unsubscribe from a channel", async () => {
|
|
41
|
+
const adapter = new InMemoryAdapter();
|
|
42
|
+
let count = 0;
|
|
43
|
+
|
|
44
|
+
await adapter.subscribe("ch", () => {
|
|
45
|
+
count++;
|
|
46
|
+
});
|
|
47
|
+
await adapter.publish("ch", "msg1");
|
|
48
|
+
expect(count).toBe(1);
|
|
49
|
+
|
|
50
|
+
await adapter.unsubscribe("ch");
|
|
51
|
+
await adapter.publish("ch", "msg2");
|
|
52
|
+
expect(count).toBe(1); // Still 1 after unsubscribe
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should clear all channels on disconnect", async () => {
|
|
56
|
+
const adapter = new InMemoryAdapter();
|
|
57
|
+
let count = 0;
|
|
58
|
+
|
|
59
|
+
await adapter.subscribe("ch1", () => {
|
|
60
|
+
count++;
|
|
61
|
+
});
|
|
62
|
+
await adapter.subscribe("ch2", () => {
|
|
63
|
+
count++;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await adapter.disconnect();
|
|
67
|
+
|
|
68
|
+
await adapter.publish("ch1", "x");
|
|
69
|
+
await adapter.publish("ch2", "x");
|
|
70
|
+
expect(count).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should swallow handler errors silently", async () => {
|
|
74
|
+
const adapter = new InMemoryAdapter();
|
|
75
|
+
let secondCalled = false;
|
|
76
|
+
|
|
77
|
+
await adapter.subscribe("ch", () => {
|
|
78
|
+
throw new Error("oops");
|
|
79
|
+
});
|
|
80
|
+
await adapter.subscribe("ch", () => {
|
|
81
|
+
secondCalled = true;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Should not throw
|
|
85
|
+
await adapter.publish("ch", "data");
|
|
86
|
+
expect(secondCalled).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { OCPPServer } from "../src/server.js";
|
|
3
|
+
import { OCPPClient } from "../src/client.js";
|
|
4
|
+
import { SecurityProfile } from "../src/types.js";
|
|
5
|
+
import type { OCPPServerClient } from "../src/server-client.js";
|
|
6
|
+
|
|
7
|
+
let server: OCPPServer;
|
|
8
|
+
let client: OCPPClient;
|
|
9
|
+
let port: number;
|
|
10
|
+
|
|
11
|
+
const getPort = (srv: import("node:http").Server): number => {
|
|
12
|
+
const addr = srv.address();
|
|
13
|
+
if (addr && typeof addr !== "string") return addr.port;
|
|
14
|
+
return 0;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("OCPPClient", () => {
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
server = new OCPPServer({ protocols: ["ocpp1.6"] });
|
|
20
|
+
server.auth((accept, _reject, _handshake) => {
|
|
21
|
+
accept({ protocol: "ocpp1.6" });
|
|
22
|
+
});
|
|
23
|
+
const httpServer = await server.listen(0);
|
|
24
|
+
port = getPort(httpServer);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
if (client) await client.close({ force: true }).catch(() => {});
|
|
29
|
+
await server.close({ force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should throw if identity is missing", () => {
|
|
33
|
+
expect(
|
|
34
|
+
() =>
|
|
35
|
+
new OCPPClient({
|
|
36
|
+
identity: "",
|
|
37
|
+
endpoint: "ws://localhost:9999",
|
|
38
|
+
}),
|
|
39
|
+
).toThrow("identity is required");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should be in CLOSED state initially", () => {
|
|
43
|
+
client = new OCPPClient({
|
|
44
|
+
identity: "CS001",
|
|
45
|
+
endpoint: `ws://localhost:${port}`,
|
|
46
|
+
protocols: ["ocpp1.6"],
|
|
47
|
+
reconnect: false,
|
|
48
|
+
});
|
|
49
|
+
expect(client.state).toBe(OCPPClient.CLOSED);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should connect successfully", async () => {
|
|
53
|
+
client = new OCPPClient({
|
|
54
|
+
identity: "CS001",
|
|
55
|
+
endpoint: `ws://localhost:${port}`,
|
|
56
|
+
protocols: ["ocpp1.6"],
|
|
57
|
+
reconnect: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await client.connect();
|
|
61
|
+
expect(client.state).toBe(OCPPClient.OPEN);
|
|
62
|
+
expect(client.protocol).toBe("ocpp1.6");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should emit "open" event on connect', async () => {
|
|
66
|
+
client = new OCPPClient({
|
|
67
|
+
identity: "CS001",
|
|
68
|
+
endpoint: `ws://localhost:${port}`,
|
|
69
|
+
protocols: ["ocpp1.6"],
|
|
70
|
+
reconnect: false,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let opened = false;
|
|
74
|
+
client.on("open", () => {
|
|
75
|
+
opened = true;
|
|
76
|
+
});
|
|
77
|
+
await client.connect();
|
|
78
|
+
expect(opened).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should reject connect when already connected", async () => {
|
|
82
|
+
client = new OCPPClient({
|
|
83
|
+
identity: "CS001",
|
|
84
|
+
endpoint: `ws://localhost:${port}`,
|
|
85
|
+
protocols: ["ocpp1.6"],
|
|
86
|
+
reconnect: false,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await client.connect();
|
|
90
|
+
await expect(client.connect()).rejects.toThrow("Cannot connect");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should close gracefully", async () => {
|
|
94
|
+
client = new OCPPClient({
|
|
95
|
+
identity: "CS001",
|
|
96
|
+
endpoint: `ws://localhost:${port}`,
|
|
97
|
+
protocols: ["ocpp1.6"],
|
|
98
|
+
reconnect: false,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await client.connect();
|
|
102
|
+
const result = await client.close();
|
|
103
|
+
expect(result.code).toBe(1000);
|
|
104
|
+
expect(client.state).toBe(OCPPClient.CLOSED);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should handle RPC call and response", async () => {
|
|
108
|
+
// Set up server handler BEFORE client connects
|
|
109
|
+
server.on("client", (serverClient: OCPPServerClient) => {
|
|
110
|
+
serverClient.handle("BootNotification", async () => {
|
|
111
|
+
return {
|
|
112
|
+
status: "Accepted",
|
|
113
|
+
currentTime: new Date().toISOString(),
|
|
114
|
+
interval: 300,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
client = new OCPPClient({
|
|
120
|
+
identity: "CS001",
|
|
121
|
+
endpoint: `ws://localhost:${port}`,
|
|
122
|
+
protocols: ["ocpp1.6"],
|
|
123
|
+
reconnect: false,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await client.connect();
|
|
127
|
+
|
|
128
|
+
const result = await client.call<{ status: string }>("BootNotification", {
|
|
129
|
+
chargePointModel: "TestModel",
|
|
130
|
+
chargePointVendor: "TestVendor",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result.status).toBe("Accepted");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should receive NotImplemented for unhandled calls", async () => {
|
|
137
|
+
// Server has no handler for UnhandledAction, so it returns NotImplemented
|
|
138
|
+
client = new OCPPClient({
|
|
139
|
+
identity: "CS001",
|
|
140
|
+
endpoint: `ws://localhost:${port}`,
|
|
141
|
+
protocols: ["ocpp1.6"],
|
|
142
|
+
reconnect: false,
|
|
143
|
+
callTimeoutMs: 2000,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await client.connect();
|
|
147
|
+
|
|
148
|
+
await expect(client.call("UnhandledAction", {})).rejects.toThrow(
|
|
149
|
+
/not known|NotImplemented/,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should support abort signal on calls", async () => {
|
|
154
|
+
client = new OCPPClient({
|
|
155
|
+
identity: "CS001",
|
|
156
|
+
endpoint: `ws://localhost:${port}`,
|
|
157
|
+
protocols: ["ocpp1.6"],
|
|
158
|
+
reconnect: false,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await client.connect();
|
|
162
|
+
|
|
163
|
+
const ac = new AbortController();
|
|
164
|
+
const callPromise = client.call("SlowAction", {}, { signal: ac.signal });
|
|
165
|
+
ac.abort();
|
|
166
|
+
|
|
167
|
+
await expect(callPromise).rejects.toThrow();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("should receive calls from server", async () => {
|
|
171
|
+
let receivedMethod = "";
|
|
172
|
+
|
|
173
|
+
client = new OCPPClient({
|
|
174
|
+
identity: "CS001",
|
|
175
|
+
endpoint: `ws://localhost:${port}`,
|
|
176
|
+
protocols: ["ocpp1.6"],
|
|
177
|
+
reconnect: false,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
client.handle("Reset", async (ctx) => {
|
|
181
|
+
receivedMethod = ctx.method;
|
|
182
|
+
return { status: "Accepted" };
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Set up server handler BEFORE connecting
|
|
186
|
+
const serverCallPromise = new Promise<void>((resolve, reject) => {
|
|
187
|
+
server.on("client", async (serverClient: OCPPServerClient) => {
|
|
188
|
+
try {
|
|
189
|
+
const result = await serverClient.call<{ status: string }>("Reset", {
|
|
190
|
+
type: "Hard",
|
|
191
|
+
});
|
|
192
|
+
expect(result.status).toBe("Accepted");
|
|
193
|
+
resolve();
|
|
194
|
+
} catch (e) {
|
|
195
|
+
reject(e);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await client.connect();
|
|
201
|
+
await serverCallPromise;
|
|
202
|
+
expect(receivedMethod).toBe("Reset");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should handle wildcard handlers", async () => {
|
|
206
|
+
let wildcardMethod = "";
|
|
207
|
+
|
|
208
|
+
client = new OCPPClient({
|
|
209
|
+
identity: "CS002",
|
|
210
|
+
endpoint: `ws://localhost:${port}`,
|
|
211
|
+
protocols: ["ocpp1.6"],
|
|
212
|
+
reconnect: false,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
client.handle((method, _ctx) => {
|
|
216
|
+
wildcardMethod = method;
|
|
217
|
+
return { status: "Accepted" };
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const serverCallPromise = new Promise<void>((resolve, reject) => {
|
|
221
|
+
server.on("client", async (serverClient: OCPPServerClient) => {
|
|
222
|
+
try {
|
|
223
|
+
await serverClient.call("AnyMethod", {});
|
|
224
|
+
resolve();
|
|
225
|
+
} catch (e) {
|
|
226
|
+
reject(e);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await client.connect();
|
|
232
|
+
await serverCallPromise;
|
|
233
|
+
expect(wildcardMethod).toBe("AnyMethod");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should remove specific handlers", async () => {
|
|
237
|
+
client = new OCPPClient({
|
|
238
|
+
identity: "CS001",
|
|
239
|
+
endpoint: `ws://localhost:${port}`,
|
|
240
|
+
protocols: ["ocpp1.6"],
|
|
241
|
+
reconnect: false,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
client.handle("Test", async () => ({ result: "ok" }));
|
|
245
|
+
client.removeHandler("Test");
|
|
246
|
+
|
|
247
|
+
const serverCallPromise = new Promise<void>((resolve, reject) => {
|
|
248
|
+
server.on("client", async (serverClient: OCPPServerClient) => {
|
|
249
|
+
try {
|
|
250
|
+
await serverClient.call("Test", {});
|
|
251
|
+
reject(new Error("Should have thrown"));
|
|
252
|
+
} catch {
|
|
253
|
+
resolve();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await client.connect();
|
|
259
|
+
await serverCallPromise;
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("OCPPClient - Security Profiles", () => {
|
|
264
|
+
afterEach(async () => {
|
|
265
|
+
if (client) await client.close({ force: true }).catch(() => {});
|
|
266
|
+
if (server) await server.close({ force: true });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("should include Basic Auth header for Profile 1", async () => {
|
|
270
|
+
let receivedPassword: Buffer | undefined;
|
|
271
|
+
|
|
272
|
+
server = new OCPPServer({ protocols: ["ocpp1.6"] });
|
|
273
|
+
server.auth((accept, _reject, handshake) => {
|
|
274
|
+
receivedPassword = handshake.password;
|
|
275
|
+
accept({ protocol: "ocpp1.6" });
|
|
276
|
+
});
|
|
277
|
+
const httpServer = await server.listen(0);
|
|
278
|
+
port = getPort(httpServer);
|
|
279
|
+
|
|
280
|
+
client = new OCPPClient({
|
|
281
|
+
identity: "CS001",
|
|
282
|
+
endpoint: `ws://localhost:${port}`,
|
|
283
|
+
protocols: ["ocpp1.6"],
|
|
284
|
+
securityProfile: SecurityProfile.BASIC_AUTH,
|
|
285
|
+
password: "myPassword123",
|
|
286
|
+
reconnect: false,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await client.connect();
|
|
290
|
+
|
|
291
|
+
// Give the server a moment to process
|
|
292
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
293
|
+
|
|
294
|
+
expect(receivedPassword).toBeDefined();
|
|
295
|
+
expect(receivedPassword!.toString()).toBe("myPassword123");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
RPCGenericError,
|
|
4
|
+
RPCNotImplementedError,
|
|
5
|
+
RPCNotSupportedError,
|
|
6
|
+
RPCInternalError,
|
|
7
|
+
RPCProtocolError,
|
|
8
|
+
RPCSecurityError,
|
|
9
|
+
RPCFormationViolationError,
|
|
10
|
+
RPCFormatViolationError,
|
|
11
|
+
RPCPropertyConstraintViolationError,
|
|
12
|
+
RPCOccurrenceConstraintViolationError,
|
|
13
|
+
RPCTypeConstraintViolationError,
|
|
14
|
+
RPCMessageTypeNotSupportedError,
|
|
15
|
+
RPCFrameworkError,
|
|
16
|
+
TimeoutError,
|
|
17
|
+
UnexpectedHttpResponse,
|
|
18
|
+
WebsocketUpgradeError,
|
|
19
|
+
} from "../src/errors.js";
|
|
20
|
+
|
|
21
|
+
describe("Error Classes", () => {
|
|
22
|
+
it("TimeoutError should have correct properties", () => {
|
|
23
|
+
const err = new TimeoutError("custom timeout");
|
|
24
|
+
expect(err.name).toBe("TimeoutError");
|
|
25
|
+
expect(err.message).toBe("custom timeout");
|
|
26
|
+
expect(err).toBeInstanceOf(Error);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("TimeoutError should have default message", () => {
|
|
30
|
+
const err = new TimeoutError();
|
|
31
|
+
expect(err.message).toBe("Operation timed out");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("UnexpectedHttpResponse should include status code and headers", () => {
|
|
35
|
+
const err = new UnexpectedHttpResponse("bad response", 401, {
|
|
36
|
+
"www-authenticate": "Basic",
|
|
37
|
+
});
|
|
38
|
+
expect(err.name).toBe("UnexpectedHttpResponse");
|
|
39
|
+
expect(err.statusCode).toBe(401);
|
|
40
|
+
expect(err.headers["www-authenticate"]).toBe("Basic");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("WebsocketUpgradeError should have default message", () => {
|
|
44
|
+
const err = new WebsocketUpgradeError();
|
|
45
|
+
expect(err.name).toBe("WebsocketUpgradeError");
|
|
46
|
+
expect(err.message).toBe("WebSocket upgrade failed");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("RPCGenericError should have correct code", () => {
|
|
50
|
+
const err = new RPCGenericError("test");
|
|
51
|
+
expect(err.rpcErrorCode).toBe("GenericError");
|
|
52
|
+
expect(err.name).toBe("RPCGenericError");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("RPCNotImplementedError should have correct code and message", () => {
|
|
56
|
+
const err = new RPCNotImplementedError("test");
|
|
57
|
+
expect(err.rpcErrorCode).toBe("NotImplemented");
|
|
58
|
+
expect(err.rpcErrorMessage).toBe("Requested method is not known");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("RPCNotSupportedError should have correct code", () => {
|
|
62
|
+
const err = new RPCNotSupportedError();
|
|
63
|
+
expect(err.rpcErrorCode).toBe("NotSupported");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("RPCInternalError should have correct code", () => {
|
|
67
|
+
const err = new RPCInternalError();
|
|
68
|
+
expect(err.rpcErrorCode).toBe("InternalError");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("RPCProtocolError should have correct code", () => {
|
|
72
|
+
const err = new RPCProtocolError();
|
|
73
|
+
expect(err.rpcErrorCode).toBe("ProtocolError");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("RPCSecurityError should have correct code", () => {
|
|
77
|
+
const err = new RPCSecurityError();
|
|
78
|
+
expect(err.rpcErrorCode).toBe("SecurityError");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("RPCFormationViolationError should have correct code", () => {
|
|
82
|
+
const err = new RPCFormationViolationError();
|
|
83
|
+
expect(err.rpcErrorCode).toBe("FormationViolation");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("RPCFormatViolationError should have correct code", () => {
|
|
87
|
+
const err = new RPCFormatViolationError();
|
|
88
|
+
expect(err.rpcErrorCode).toBe("FormatViolation");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("RPCPropertyConstraintViolationError should have correct code", () => {
|
|
92
|
+
const err = new RPCPropertyConstraintViolationError();
|
|
93
|
+
expect(err.rpcErrorCode).toBe("PropertyConstraintViolation");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("RPCOccurrenceConstraintViolationError should have correct code", () => {
|
|
97
|
+
const err = new RPCOccurrenceConstraintViolationError();
|
|
98
|
+
expect(err.rpcErrorCode).toBe("OccurrenceConstraintViolation");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("RPCTypeConstraintViolationError should have correct code", () => {
|
|
102
|
+
const err = new RPCTypeConstraintViolationError();
|
|
103
|
+
expect(err.rpcErrorCode).toBe("TypeConstraintViolation");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("RPCMessageTypeNotSupportedError should have correct code", () => {
|
|
107
|
+
const err = new RPCMessageTypeNotSupportedError();
|
|
108
|
+
expect(err.rpcErrorCode).toBe("MessageTypeNotSupported");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("RPCFrameworkError should have correct code", () => {
|
|
112
|
+
const err = new RPCFrameworkError();
|
|
113
|
+
expect(err.rpcErrorCode).toBe("RpcFrameworkError");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("all RPC errors should extend RPCGenericError chain", () => {
|
|
117
|
+
const errors = [
|
|
118
|
+
new RPCGenericError(),
|
|
119
|
+
new RPCNotImplementedError(),
|
|
120
|
+
new RPCInternalError(),
|
|
121
|
+
new RPCFormatViolationError(),
|
|
122
|
+
];
|
|
123
|
+
for (const err of errors) {
|
|
124
|
+
expect(err).toBeInstanceOf(Error);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("RPC errors should carry details", () => {
|
|
129
|
+
const err = new RPCGenericError("msg", { foo: "bar" });
|
|
130
|
+
expect(err.details).toEqual({ foo: "bar" });
|
|
131
|
+
});
|
|
132
|
+
});
|