@aegis-fluxion/core 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1200 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +179 -0
- package/dist/index.d.ts +179 -0
- package/dist/index.js +1193 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
import { randomUUID, createDecipheriv, randomBytes, createCipheriv, createECDH, createHash } from 'crypto';
|
|
2
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
var DEFAULT_CLOSE_CODE = 1e3;
|
|
6
|
+
var DEFAULT_CLOSE_REASON = "";
|
|
7
|
+
var INTERNAL_HANDSHAKE_EVENT = "__handshake";
|
|
8
|
+
var READY_EVENT = "ready";
|
|
9
|
+
var HANDSHAKE_CURVE = "prime256v1";
|
|
10
|
+
var ENCRYPTION_ALGORITHM = "aes-256-gcm";
|
|
11
|
+
var GCM_IV_LENGTH = 12;
|
|
12
|
+
var GCM_AUTH_TAG_LENGTH = 16;
|
|
13
|
+
var ENCRYPTION_KEY_LENGTH = 32;
|
|
14
|
+
var ENCRYPTED_PACKET_VERSION = 1;
|
|
15
|
+
var ENCRYPTED_PACKET_PREFIX_LENGTH = 1 + GCM_IV_LENGTH + GCM_AUTH_TAG_LENGTH;
|
|
16
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
17
|
+
var DEFAULT_HEARTBEAT_TIMEOUT_MS = 15e3;
|
|
18
|
+
var DEFAULT_RECONNECT_INITIAL_DELAY_MS = 250;
|
|
19
|
+
var DEFAULT_RECONNECT_MAX_DELAY_MS = 1e4;
|
|
20
|
+
var DEFAULT_RECONNECT_FACTOR = 2;
|
|
21
|
+
var DEFAULT_RECONNECT_JITTER_RATIO = 0.2;
|
|
22
|
+
function normalizeToError(error, fallbackMessage) {
|
|
23
|
+
if (error instanceof Error) {
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
if (typeof error === "string" && error.trim().length > 0) {
|
|
27
|
+
return new Error(error);
|
|
28
|
+
}
|
|
29
|
+
return new Error(fallbackMessage);
|
|
30
|
+
}
|
|
31
|
+
function decodeRawData(rawData) {
|
|
32
|
+
if (typeof rawData === "string") {
|
|
33
|
+
return rawData;
|
|
34
|
+
}
|
|
35
|
+
if (rawData instanceof ArrayBuffer) {
|
|
36
|
+
return Buffer.from(rawData).toString("utf8");
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(rawData)) {
|
|
39
|
+
return Buffer.concat(rawData).toString("utf8");
|
|
40
|
+
}
|
|
41
|
+
return rawData.toString("utf8");
|
|
42
|
+
}
|
|
43
|
+
function rawDataToBuffer(rawData) {
|
|
44
|
+
if (typeof rawData === "string") {
|
|
45
|
+
return Buffer.from(rawData, "utf8");
|
|
46
|
+
}
|
|
47
|
+
if (rawData instanceof ArrayBuffer) {
|
|
48
|
+
return Buffer.from(rawData);
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(rawData)) {
|
|
51
|
+
return Buffer.concat(rawData);
|
|
52
|
+
}
|
|
53
|
+
return Buffer.from(rawData);
|
|
54
|
+
}
|
|
55
|
+
function serializeEnvelope(event, data) {
|
|
56
|
+
const envelope = { event, data };
|
|
57
|
+
return JSON.stringify(envelope);
|
|
58
|
+
}
|
|
59
|
+
function parseEnvelope(rawData) {
|
|
60
|
+
const decoded = decodeRawData(rawData);
|
|
61
|
+
const parsed = JSON.parse(decoded);
|
|
62
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.event !== "string") {
|
|
63
|
+
throw new Error("Invalid message format. Expected { event: string, data: unknown }.");
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
event: parsed.event,
|
|
67
|
+
data: parsed.data
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function parseEnvelopeFromText(decodedPayload) {
|
|
71
|
+
const parsed = JSON.parse(decodedPayload);
|
|
72
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.event !== "string") {
|
|
73
|
+
throw new Error("Invalid message format. Expected { event: string, data: unknown }.");
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
event: parsed.event,
|
|
77
|
+
data: parsed.data
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function decodeCloseReason(reason) {
|
|
81
|
+
return reason.toString("utf8");
|
|
82
|
+
}
|
|
83
|
+
function isReservedEmitEvent(event) {
|
|
84
|
+
return event === INTERNAL_HANDSHAKE_EVENT || event === READY_EVENT;
|
|
85
|
+
}
|
|
86
|
+
function createEphemeralHandshakeState() {
|
|
87
|
+
const ecdh = createECDH(HANDSHAKE_CURVE);
|
|
88
|
+
ecdh.generateKeys();
|
|
89
|
+
return {
|
|
90
|
+
ecdh,
|
|
91
|
+
localPublicKey: ecdh.getPublicKey("base64")
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function parseHandshakePayload(data) {
|
|
95
|
+
if (typeof data !== "object" || data === null) {
|
|
96
|
+
throw new Error("Invalid handshake payload format.");
|
|
97
|
+
}
|
|
98
|
+
const payload = data;
|
|
99
|
+
if (typeof payload.publicKey !== "string" || payload.publicKey.length === 0) {
|
|
100
|
+
throw new Error("Handshake payload must include a non-empty public key.");
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
publicKey: payload.publicKey
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function deriveEncryptionKey(sharedSecret) {
|
|
107
|
+
const derivedKey = createHash("sha256").update(sharedSecret).digest();
|
|
108
|
+
if (derivedKey.length !== ENCRYPTION_KEY_LENGTH) {
|
|
109
|
+
throw new Error("Failed to derive a valid AES-256 key.");
|
|
110
|
+
}
|
|
111
|
+
return derivedKey;
|
|
112
|
+
}
|
|
113
|
+
function encryptSerializedEnvelope(serializedEnvelope, encryptionKey) {
|
|
114
|
+
const iv = randomBytes(GCM_IV_LENGTH);
|
|
115
|
+
const cipher = createCipheriv(ENCRYPTION_ALGORITHM, encryptionKey, iv);
|
|
116
|
+
const ciphertext = Buffer.concat([
|
|
117
|
+
cipher.update(serializedEnvelope, "utf8"),
|
|
118
|
+
cipher.final()
|
|
119
|
+
]);
|
|
120
|
+
const authTag = cipher.getAuthTag();
|
|
121
|
+
return Buffer.concat([
|
|
122
|
+
Buffer.from([ENCRYPTED_PACKET_VERSION]),
|
|
123
|
+
iv,
|
|
124
|
+
authTag,
|
|
125
|
+
ciphertext
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
function parseEncryptedPacket(rawData) {
|
|
129
|
+
const packetBuffer = rawDataToBuffer(rawData);
|
|
130
|
+
if (packetBuffer.length <= ENCRYPTED_PACKET_PREFIX_LENGTH) {
|
|
131
|
+
throw new Error("Encrypted packet is too short.");
|
|
132
|
+
}
|
|
133
|
+
const version = packetBuffer.readUInt8(0);
|
|
134
|
+
if (version !== ENCRYPTED_PACKET_VERSION) {
|
|
135
|
+
throw new Error("Unsupported encrypted packet version.");
|
|
136
|
+
}
|
|
137
|
+
const ivStart = 1;
|
|
138
|
+
const ivEnd = ivStart + GCM_IV_LENGTH;
|
|
139
|
+
const authTagStart = ivEnd;
|
|
140
|
+
const authTagEnd = authTagStart + GCM_AUTH_TAG_LENGTH;
|
|
141
|
+
const iv = packetBuffer.subarray(ivStart, ivEnd);
|
|
142
|
+
const authTag = packetBuffer.subarray(authTagStart, authTagEnd);
|
|
143
|
+
const ciphertext = packetBuffer.subarray(authTagEnd);
|
|
144
|
+
if (ciphertext.length === 0) {
|
|
145
|
+
throw new Error("Encrypted payload is empty.");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
iv,
|
|
149
|
+
authTag,
|
|
150
|
+
ciphertext
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function decryptSerializedEnvelope(rawData, encryptionKey) {
|
|
154
|
+
const encryptedPacket = parseEncryptedPacket(rawData);
|
|
155
|
+
const decipher = createDecipheriv(
|
|
156
|
+
ENCRYPTION_ALGORITHM,
|
|
157
|
+
encryptionKey,
|
|
158
|
+
encryptedPacket.iv
|
|
159
|
+
);
|
|
160
|
+
decipher.setAuthTag(encryptedPacket.authTag);
|
|
161
|
+
const plaintext = Buffer.concat([
|
|
162
|
+
decipher.update(encryptedPacket.ciphertext),
|
|
163
|
+
decipher.final()
|
|
164
|
+
]);
|
|
165
|
+
return plaintext.toString("utf8");
|
|
166
|
+
}
|
|
167
|
+
var SecureServer = class {
|
|
168
|
+
socketServer;
|
|
169
|
+
heartbeatConfig;
|
|
170
|
+
heartbeatIntervalHandle = null;
|
|
171
|
+
clientsById = /* @__PURE__ */ new Map();
|
|
172
|
+
clientIdBySocket = /* @__PURE__ */ new Map();
|
|
173
|
+
customEventHandlers = /* @__PURE__ */ new Map();
|
|
174
|
+
connectionHandlers = /* @__PURE__ */ new Set();
|
|
175
|
+
disconnectHandlers = /* @__PURE__ */ new Set();
|
|
176
|
+
readyHandlers = /* @__PURE__ */ new Set();
|
|
177
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
178
|
+
handshakeStateBySocket = /* @__PURE__ */ new WeakMap();
|
|
179
|
+
sharedSecretBySocket = /* @__PURE__ */ new WeakMap();
|
|
180
|
+
encryptionKeyBySocket = /* @__PURE__ */ new WeakMap();
|
|
181
|
+
pendingPayloadsBySocket = /* @__PURE__ */ new WeakMap();
|
|
182
|
+
heartbeatStateBySocket = /* @__PURE__ */ new WeakMap();
|
|
183
|
+
roomMembersByName = /* @__PURE__ */ new Map();
|
|
184
|
+
roomNamesByClientId = /* @__PURE__ */ new Map();
|
|
185
|
+
constructor(options) {
|
|
186
|
+
const { heartbeat, ...socketServerOptions } = options;
|
|
187
|
+
this.heartbeatConfig = this.resolveHeartbeatConfig(heartbeat);
|
|
188
|
+
this.socketServer = new WebSocketServer(socketServerOptions);
|
|
189
|
+
this.bindSocketServerEvents();
|
|
190
|
+
this.startHeartbeatLoop();
|
|
191
|
+
}
|
|
192
|
+
get clientCount() {
|
|
193
|
+
return this.clientsById.size;
|
|
194
|
+
}
|
|
195
|
+
get clients() {
|
|
196
|
+
return this.clientsById;
|
|
197
|
+
}
|
|
198
|
+
on(event, handler) {
|
|
199
|
+
try {
|
|
200
|
+
if (event === "connection") {
|
|
201
|
+
this.connectionHandlers.add(handler);
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
if (event === "disconnect") {
|
|
205
|
+
this.disconnectHandlers.add(handler);
|
|
206
|
+
return this;
|
|
207
|
+
}
|
|
208
|
+
if (event === READY_EVENT) {
|
|
209
|
+
this.readyHandlers.add(handler);
|
|
210
|
+
return this;
|
|
211
|
+
}
|
|
212
|
+
if (event === "error") {
|
|
213
|
+
this.errorHandlers.add(handler);
|
|
214
|
+
return this;
|
|
215
|
+
}
|
|
216
|
+
if (event === INTERNAL_HANDSHAKE_EVENT) {
|
|
217
|
+
throw new Error(`The event "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
|
|
218
|
+
}
|
|
219
|
+
const typedHandler = handler;
|
|
220
|
+
const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
221
|
+
listeners.add(typedHandler);
|
|
222
|
+
this.customEventHandlers.set(event, listeners);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
this.notifyError(
|
|
225
|
+
normalizeToError(error, "Failed to register server event handler.")
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return this;
|
|
229
|
+
}
|
|
230
|
+
off(event, handler) {
|
|
231
|
+
try {
|
|
232
|
+
if (event === "connection") {
|
|
233
|
+
this.connectionHandlers.delete(handler);
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
if (event === "disconnect") {
|
|
237
|
+
this.disconnectHandlers.delete(handler);
|
|
238
|
+
return this;
|
|
239
|
+
}
|
|
240
|
+
if (event === READY_EVENT) {
|
|
241
|
+
this.readyHandlers.delete(handler);
|
|
242
|
+
return this;
|
|
243
|
+
}
|
|
244
|
+
if (event === "error") {
|
|
245
|
+
this.errorHandlers.delete(handler);
|
|
246
|
+
return this;
|
|
247
|
+
}
|
|
248
|
+
if (event === INTERNAL_HANDSHAKE_EVENT) {
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
const listeners = this.customEventHandlers.get(event);
|
|
252
|
+
if (!listeners) {
|
|
253
|
+
return this;
|
|
254
|
+
}
|
|
255
|
+
listeners.delete(handler);
|
|
256
|
+
if (listeners.size === 0) {
|
|
257
|
+
this.customEventHandlers.delete(event);
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
this.notifyError(
|
|
261
|
+
normalizeToError(error, "Failed to remove server event handler.")
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return this;
|
|
265
|
+
}
|
|
266
|
+
emit(event, data) {
|
|
267
|
+
try {
|
|
268
|
+
if (isReservedEmitEvent(event)) {
|
|
269
|
+
throw new Error(`The event "${event}" is reserved and cannot be emitted manually.`);
|
|
270
|
+
}
|
|
271
|
+
const envelope = { event, data };
|
|
272
|
+
for (const client of this.clientsById.values()) {
|
|
273
|
+
this.sendOrQueuePayload(client.socket, envelope);
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
this.notifyError(normalizeToError(error, "Failed to emit server event."));
|
|
277
|
+
}
|
|
278
|
+
return this;
|
|
279
|
+
}
|
|
280
|
+
emitTo(clientId, event, data) {
|
|
281
|
+
try {
|
|
282
|
+
if (isReservedEmitEvent(event)) {
|
|
283
|
+
throw new Error(`The event "${event}" is reserved and cannot be emitted manually.`);
|
|
284
|
+
}
|
|
285
|
+
const client = this.clientsById.get(clientId);
|
|
286
|
+
if (!client) {
|
|
287
|
+
throw new Error(`Client with id ${clientId} was not found.`);
|
|
288
|
+
}
|
|
289
|
+
this.sendOrQueuePayload(client.socket, { event, data });
|
|
290
|
+
return true;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.notifyError(normalizeToError(error, "Failed to emit event to client."));
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
to(room) {
|
|
297
|
+
const normalizedRoom = this.normalizeRoomName(room);
|
|
298
|
+
return {
|
|
299
|
+
emit: (event, data) => {
|
|
300
|
+
try {
|
|
301
|
+
this.emitToRoom(normalizedRoom, event, data);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
this.notifyError(
|
|
304
|
+
normalizeToError(error, `Failed to emit event to room ${normalizedRoom}.`)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return this;
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
close(code = DEFAULT_CLOSE_CODE, reason = DEFAULT_CLOSE_REASON) {
|
|
312
|
+
try {
|
|
313
|
+
this.stopHeartbeatLoop();
|
|
314
|
+
for (const client of this.clientsById.values()) {
|
|
315
|
+
if (client.socket.readyState === WebSocket.OPEN || client.socket.readyState === WebSocket.CONNECTING) {
|
|
316
|
+
client.socket.close(code, reason);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
this.socketServer.close();
|
|
320
|
+
} catch (error) {
|
|
321
|
+
this.notifyError(normalizeToError(error, "Failed to close server."));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
resolveHeartbeatConfig(heartbeatOptions) {
|
|
325
|
+
const intervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
326
|
+
const timeoutMs = heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
327
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
328
|
+
throw new Error("Server heartbeat intervalMs must be a positive number.");
|
|
329
|
+
}
|
|
330
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
331
|
+
throw new Error("Server heartbeat timeoutMs must be a positive number.");
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
enabled: heartbeatOptions?.enabled ?? true,
|
|
335
|
+
intervalMs,
|
|
336
|
+
timeoutMs
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
startHeartbeatLoop() {
|
|
340
|
+
if (!this.heartbeatConfig.enabled || this.heartbeatIntervalHandle) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.heartbeatIntervalHandle = setInterval(() => {
|
|
344
|
+
this.performHeartbeatSweep();
|
|
345
|
+
}, this.heartbeatConfig.intervalMs);
|
|
346
|
+
this.heartbeatIntervalHandle.unref?.();
|
|
347
|
+
}
|
|
348
|
+
stopHeartbeatLoop() {
|
|
349
|
+
if (!this.heartbeatIntervalHandle) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
clearInterval(this.heartbeatIntervalHandle);
|
|
353
|
+
this.heartbeatIntervalHandle = null;
|
|
354
|
+
}
|
|
355
|
+
performHeartbeatSweep() {
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
for (const client of this.clientsById.values()) {
|
|
358
|
+
const socket = client.socket;
|
|
359
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const heartbeatState = this.heartbeatStateBySocket.get(socket) ?? {
|
|
363
|
+
awaitingPong: false,
|
|
364
|
+
lastPingAt: 0
|
|
365
|
+
};
|
|
366
|
+
if (heartbeatState.awaitingPong && now - heartbeatState.lastPingAt >= this.heartbeatConfig.timeoutMs) {
|
|
367
|
+
this.sharedSecretBySocket.delete(socket);
|
|
368
|
+
this.encryptionKeyBySocket.delete(socket);
|
|
369
|
+
this.pendingPayloadsBySocket.delete(socket);
|
|
370
|
+
this.handshakeStateBySocket.delete(socket);
|
|
371
|
+
this.heartbeatStateBySocket.delete(socket);
|
|
372
|
+
socket.terminate();
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (heartbeatState.awaitingPong) {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
heartbeatState.awaitingPong = true;
|
|
379
|
+
heartbeatState.lastPingAt = now;
|
|
380
|
+
this.heartbeatStateBySocket.set(socket, heartbeatState);
|
|
381
|
+
try {
|
|
382
|
+
socket.ping();
|
|
383
|
+
} catch (error) {
|
|
384
|
+
this.notifyError(
|
|
385
|
+
normalizeToError(error, `Failed to send heartbeat ping to client ${client.id}.`)
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
handleHeartbeatPong(socket) {
|
|
391
|
+
const heartbeatState = this.heartbeatStateBySocket.get(socket);
|
|
392
|
+
if (!heartbeatState) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
heartbeatState.awaitingPong = false;
|
|
396
|
+
heartbeatState.lastPingAt = 0;
|
|
397
|
+
this.heartbeatStateBySocket.set(socket, heartbeatState);
|
|
398
|
+
}
|
|
399
|
+
bindSocketServerEvents() {
|
|
400
|
+
this.socketServer.on("connection", (socket, request) => {
|
|
401
|
+
this.handleConnection(socket, request);
|
|
402
|
+
});
|
|
403
|
+
this.socketServer.on("error", (error) => {
|
|
404
|
+
this.notifyError(normalizeToError(error, "WebSocket server encountered an error."));
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
handleConnection(socket, request) {
|
|
408
|
+
try {
|
|
409
|
+
const clientId = randomUUID();
|
|
410
|
+
const handshakeState = this.createServerHandshakeState();
|
|
411
|
+
const client = this.createSecureServerClient(clientId, socket, request);
|
|
412
|
+
this.clientsById.set(clientId, client);
|
|
413
|
+
this.clientIdBySocket.set(socket, clientId);
|
|
414
|
+
this.handshakeStateBySocket.set(socket, handshakeState);
|
|
415
|
+
this.pendingPayloadsBySocket.set(socket, []);
|
|
416
|
+
this.heartbeatStateBySocket.set(socket, {
|
|
417
|
+
awaitingPong: false,
|
|
418
|
+
lastPingAt: 0
|
|
419
|
+
});
|
|
420
|
+
this.roomNamesByClientId.set(clientId, /* @__PURE__ */ new Set());
|
|
421
|
+
socket.on("message", (rawData) => {
|
|
422
|
+
this.handleIncomingMessage(client, rawData);
|
|
423
|
+
});
|
|
424
|
+
socket.on("close", (code, reason) => {
|
|
425
|
+
this.handleDisconnection(client, code, reason);
|
|
426
|
+
});
|
|
427
|
+
socket.on("pong", () => {
|
|
428
|
+
this.handleHeartbeatPong(client.socket);
|
|
429
|
+
});
|
|
430
|
+
socket.on("error", (error) => {
|
|
431
|
+
this.notifyError(
|
|
432
|
+
normalizeToError(
|
|
433
|
+
error,
|
|
434
|
+
`Client socket error detected for client ${client.id}.`
|
|
435
|
+
)
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
this.sendInternalHandshake(socket, handshakeState.localPublicKey);
|
|
439
|
+
this.notifyConnection(client);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
this.notifyError(normalizeToError(error, "Failed to handle client connection."));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
handleIncomingMessage(client, rawData) {
|
|
445
|
+
try {
|
|
446
|
+
let envelope = null;
|
|
447
|
+
try {
|
|
448
|
+
envelope = parseEnvelope(rawData);
|
|
449
|
+
} catch {
|
|
450
|
+
envelope = null;
|
|
451
|
+
}
|
|
452
|
+
if (envelope?.event === INTERNAL_HANDSHAKE_EVENT) {
|
|
453
|
+
this.handleInternalHandshake(client, envelope.data);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (envelope !== null) {
|
|
457
|
+
this.notifyError(
|
|
458
|
+
new Error(
|
|
459
|
+
`Plaintext event "${envelope.event}" was rejected for client ${client.id}.`
|
|
460
|
+
)
|
|
461
|
+
);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (!this.isClientHandshakeReady(client.socket)) {
|
|
465
|
+
this.notifyError(
|
|
466
|
+
new Error(`Encrypted payload was received before handshake completion for client ${client.id}.`)
|
|
467
|
+
);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const encryptionKey = this.encryptionKeyBySocket.get(client.socket);
|
|
471
|
+
if (!encryptionKey) {
|
|
472
|
+
this.notifyError(new Error(`Missing encryption key for client ${client.id}.`));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
let decryptedPayload;
|
|
476
|
+
try {
|
|
477
|
+
decryptedPayload = decryptSerializedEnvelope(rawData, encryptionKey);
|
|
478
|
+
} catch {
|
|
479
|
+
console.warn("Tampered data detected and dropped");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const decryptedEnvelope = parseEnvelopeFromText(decryptedPayload);
|
|
483
|
+
this.dispatchCustomEvent(decryptedEnvelope.event, decryptedEnvelope.data, client);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
this.notifyError(normalizeToError(error, "Failed to process incoming server message."));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
handleDisconnection(client, code, reason) {
|
|
489
|
+
try {
|
|
490
|
+
client.leaveAll();
|
|
491
|
+
this.clientsById.delete(client.id);
|
|
492
|
+
this.clientIdBySocket.delete(client.socket);
|
|
493
|
+
this.handshakeStateBySocket.delete(client.socket);
|
|
494
|
+
this.sharedSecretBySocket.delete(client.socket);
|
|
495
|
+
this.encryptionKeyBySocket.delete(client.socket);
|
|
496
|
+
this.pendingPayloadsBySocket.delete(client.socket);
|
|
497
|
+
this.heartbeatStateBySocket.delete(client.socket);
|
|
498
|
+
const decodedReason = decodeCloseReason(reason);
|
|
499
|
+
for (const handler of this.disconnectHandlers) {
|
|
500
|
+
try {
|
|
501
|
+
handler(client, code, decodedReason);
|
|
502
|
+
} catch (handlerError) {
|
|
503
|
+
this.notifyError(
|
|
504
|
+
normalizeToError(
|
|
505
|
+
handlerError,
|
|
506
|
+
`Disconnect handler failed for client ${client.id}.`
|
|
507
|
+
)
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} catch (error) {
|
|
512
|
+
this.notifyError(normalizeToError(error, "Failed to process client disconnection."));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
dispatchCustomEvent(event, data, client) {
|
|
516
|
+
const handlers = this.customEventHandlers.get(event);
|
|
517
|
+
if (!handlers || handlers.size === 0) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
for (const handler of handlers) {
|
|
521
|
+
try {
|
|
522
|
+
handler(data, client);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
this.notifyError(
|
|
525
|
+
normalizeToError(
|
|
526
|
+
error,
|
|
527
|
+
`Server event handler failed for event ${event}.`
|
|
528
|
+
)
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
sendRaw(socket, payload) {
|
|
534
|
+
try {
|
|
535
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
socket.send(payload);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
this.notifyError(normalizeToError(error, "Failed to send server payload."));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
sendEncryptedEnvelope(socket, envelope) {
|
|
544
|
+
try {
|
|
545
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const encryptionKey = this.encryptionKeyBySocket.get(socket);
|
|
549
|
+
if (!encryptionKey) {
|
|
550
|
+
throw new Error("Missing encryption key for connected socket.");
|
|
551
|
+
}
|
|
552
|
+
const encryptedPayload = encryptSerializedEnvelope(
|
|
553
|
+
serializeEnvelope(envelope.event, envelope.data),
|
|
554
|
+
encryptionKey
|
|
555
|
+
);
|
|
556
|
+
socket.send(encryptedPayload);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
this.notifyError(normalizeToError(error, "Failed to send encrypted server payload."));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
notifyConnection(client) {
|
|
562
|
+
for (const handler of this.connectionHandlers) {
|
|
563
|
+
try {
|
|
564
|
+
handler(client);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
this.notifyError(
|
|
567
|
+
normalizeToError(error, `Connection handler failed for client ${client.id}.`)
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
notifyReady(client) {
|
|
573
|
+
for (const handler of this.readyHandlers) {
|
|
574
|
+
try {
|
|
575
|
+
handler(client);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
this.notifyError(
|
|
578
|
+
normalizeToError(error, `Ready handler failed for client ${client.id}.`)
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
notifyError(error) {
|
|
584
|
+
if (this.errorHandlers.size === 0) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
for (const handler of this.errorHandlers) {
|
|
588
|
+
try {
|
|
589
|
+
handler(error);
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
createServerHandshakeState() {
|
|
595
|
+
const { ecdh, localPublicKey } = createEphemeralHandshakeState();
|
|
596
|
+
return {
|
|
597
|
+
ecdh,
|
|
598
|
+
localPublicKey,
|
|
599
|
+
isReady: false
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
sendInternalHandshake(socket, localPublicKey) {
|
|
603
|
+
this.sendRaw(
|
|
604
|
+
socket,
|
|
605
|
+
serializeEnvelope(INTERNAL_HANDSHAKE_EVENT, {
|
|
606
|
+
publicKey: localPublicKey
|
|
607
|
+
})
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
handleInternalHandshake(client, data) {
|
|
611
|
+
try {
|
|
612
|
+
const payload = parseHandshakePayload(data);
|
|
613
|
+
const handshakeState = this.handshakeStateBySocket.get(client.socket);
|
|
614
|
+
if (!handshakeState) {
|
|
615
|
+
throw new Error(`Missing handshake state for client ${client.id}.`);
|
|
616
|
+
}
|
|
617
|
+
if (handshakeState.isReady) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const remotePublicKey = Buffer.from(payload.publicKey, "base64");
|
|
621
|
+
const sharedSecret = handshakeState.ecdh.computeSecret(remotePublicKey);
|
|
622
|
+
const encryptionKey = deriveEncryptionKey(sharedSecret);
|
|
623
|
+
this.sharedSecretBySocket.set(client.socket, sharedSecret);
|
|
624
|
+
this.encryptionKeyBySocket.set(client.socket, encryptionKey);
|
|
625
|
+
handshakeState.isReady = true;
|
|
626
|
+
this.flushQueuedPayloads(client.socket);
|
|
627
|
+
this.notifyReady(client);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
this.notifyError(normalizeToError(error, "Failed to complete server handshake."));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
isClientHandshakeReady(socket) {
|
|
633
|
+
return this.handshakeStateBySocket.get(socket)?.isReady ?? false;
|
|
634
|
+
}
|
|
635
|
+
sendOrQueuePayload(socket, envelope) {
|
|
636
|
+
if (!this.isClientHandshakeReady(socket)) {
|
|
637
|
+
this.queuePayload(socket, envelope);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
this.sendEncryptedEnvelope(socket, envelope);
|
|
641
|
+
}
|
|
642
|
+
queuePayload(socket, envelope) {
|
|
643
|
+
const pendingPayloads = this.pendingPayloadsBySocket.get(socket) ?? [];
|
|
644
|
+
pendingPayloads.push(envelope);
|
|
645
|
+
this.pendingPayloadsBySocket.set(socket, pendingPayloads);
|
|
646
|
+
}
|
|
647
|
+
flushQueuedPayloads(socket) {
|
|
648
|
+
const pendingPayloads = this.pendingPayloadsBySocket.get(socket);
|
|
649
|
+
if (!pendingPayloads || pendingPayloads.length === 0) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
this.pendingPayloadsBySocket.delete(socket);
|
|
653
|
+
for (const envelope of pendingPayloads) {
|
|
654
|
+
this.sendEncryptedEnvelope(socket, envelope);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
createSecureServerClient(clientId, socket, request) {
|
|
658
|
+
return {
|
|
659
|
+
id: clientId,
|
|
660
|
+
socket,
|
|
661
|
+
request,
|
|
662
|
+
join: (room) => this.joinClientToRoom(clientId, room),
|
|
663
|
+
leave: (room) => this.leaveClientFromRoom(clientId, room),
|
|
664
|
+
leaveAll: () => this.leaveClientFromAllRooms(clientId)
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
normalizeRoomName(room) {
|
|
668
|
+
if (typeof room !== "string") {
|
|
669
|
+
throw new Error("Room name must be a string.");
|
|
670
|
+
}
|
|
671
|
+
const normalizedRoom = room.trim();
|
|
672
|
+
if (normalizedRoom.length === 0) {
|
|
673
|
+
throw new Error("Room name cannot be empty.");
|
|
674
|
+
}
|
|
675
|
+
return normalizedRoom;
|
|
676
|
+
}
|
|
677
|
+
joinClientToRoom(clientId, room) {
|
|
678
|
+
const normalizedRoom = this.normalizeRoomName(room);
|
|
679
|
+
if (!this.clientsById.has(clientId)) {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
const clientRooms = this.roomNamesByClientId.get(clientId) ?? /* @__PURE__ */ new Set();
|
|
683
|
+
if (clientRooms.has(normalizedRoom)) {
|
|
684
|
+
this.roomNamesByClientId.set(clientId, clientRooms);
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
clientRooms.add(normalizedRoom);
|
|
688
|
+
this.roomNamesByClientId.set(clientId, clientRooms);
|
|
689
|
+
const roomMembers = this.roomMembersByName.get(normalizedRoom) ?? /* @__PURE__ */ new Set();
|
|
690
|
+
roomMembers.add(clientId);
|
|
691
|
+
this.roomMembersByName.set(normalizedRoom, roomMembers);
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
leaveClientFromRoom(clientId, room) {
|
|
695
|
+
const normalizedRoom = this.normalizeRoomName(room);
|
|
696
|
+
const clientRooms = this.roomNamesByClientId.get(clientId);
|
|
697
|
+
if (!clientRooms || !clientRooms.delete(normalizedRoom)) {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
if (clientRooms.size === 0) {
|
|
701
|
+
this.roomNamesByClientId.delete(clientId);
|
|
702
|
+
}
|
|
703
|
+
const roomMembers = this.roomMembersByName.get(normalizedRoom);
|
|
704
|
+
if (roomMembers) {
|
|
705
|
+
roomMembers.delete(clientId);
|
|
706
|
+
if (roomMembers.size === 0) {
|
|
707
|
+
this.roomMembersByName.delete(normalizedRoom);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
leaveClientFromAllRooms(clientId) {
|
|
713
|
+
const clientRooms = this.roomNamesByClientId.get(clientId);
|
|
714
|
+
if (!clientRooms || clientRooms.size === 0) {
|
|
715
|
+
this.roomNamesByClientId.delete(clientId);
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
const roomNames = [...clientRooms];
|
|
719
|
+
this.roomNamesByClientId.delete(clientId);
|
|
720
|
+
for (const roomName of roomNames) {
|
|
721
|
+
const roomMembers = this.roomMembersByName.get(roomName);
|
|
722
|
+
if (!roomMembers) {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
roomMembers.delete(clientId);
|
|
726
|
+
if (roomMembers.size === 0) {
|
|
727
|
+
this.roomMembersByName.delete(roomName);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return roomNames.length;
|
|
731
|
+
}
|
|
732
|
+
emitToRoom(room, event, data) {
|
|
733
|
+
if (isReservedEmitEvent(event)) {
|
|
734
|
+
throw new Error(`The event "${event}" is reserved and cannot be emitted manually.`);
|
|
735
|
+
}
|
|
736
|
+
const roomMembers = this.roomMembersByName.get(room);
|
|
737
|
+
if (!roomMembers || roomMembers.size === 0) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const envelope = { event, data };
|
|
741
|
+
for (const clientId of roomMembers) {
|
|
742
|
+
const client = this.clientsById.get(clientId);
|
|
743
|
+
if (!client) {
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
this.sendOrQueuePayload(client.socket, envelope);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
var SecureClient = class {
|
|
751
|
+
constructor(url, options = {}) {
|
|
752
|
+
this.url = url;
|
|
753
|
+
this.options = options;
|
|
754
|
+
this.reconnectConfig = this.resolveReconnectConfig(this.options.reconnect);
|
|
755
|
+
if (this.options.autoConnect ?? true) {
|
|
756
|
+
this.connect();
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
url;
|
|
760
|
+
options;
|
|
761
|
+
socket = null;
|
|
762
|
+
reconnectConfig;
|
|
763
|
+
reconnectAttemptCount = 0;
|
|
764
|
+
reconnectTimer = null;
|
|
765
|
+
isManualDisconnectRequested = false;
|
|
766
|
+
customEventHandlers = /* @__PURE__ */ new Map();
|
|
767
|
+
connectHandlers = /* @__PURE__ */ new Set();
|
|
768
|
+
disconnectHandlers = /* @__PURE__ */ new Set();
|
|
769
|
+
readyHandlers = /* @__PURE__ */ new Set();
|
|
770
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
771
|
+
handshakeState = null;
|
|
772
|
+
pendingPayloadQueue = [];
|
|
773
|
+
get readyState() {
|
|
774
|
+
return this.socket?.readyState ?? null;
|
|
775
|
+
}
|
|
776
|
+
isConnected() {
|
|
777
|
+
return this.socket?.readyState === WebSocket.OPEN;
|
|
778
|
+
}
|
|
779
|
+
connect() {
|
|
780
|
+
try {
|
|
781
|
+
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
this.clearReconnectTimer();
|
|
785
|
+
this.isManualDisconnectRequested = false;
|
|
786
|
+
const socket = this.createSocket();
|
|
787
|
+
this.socket = socket;
|
|
788
|
+
this.handshakeState = this.createClientHandshakeState();
|
|
789
|
+
this.pendingPayloadQueue = [];
|
|
790
|
+
this.bindSocketEvents(socket);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
this.notifyError(normalizeToError(error, "Failed to connect client."));
|
|
793
|
+
if (!this.isManualDisconnectRequested) {
|
|
794
|
+
this.scheduleReconnect();
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
disconnect(code = DEFAULT_CLOSE_CODE, reason = DEFAULT_CLOSE_REASON) {
|
|
799
|
+
try {
|
|
800
|
+
this.isManualDisconnectRequested = true;
|
|
801
|
+
this.clearReconnectTimer();
|
|
802
|
+
if (!this.socket) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (this.socket.readyState === WebSocket.CLOSING || this.socket.readyState === WebSocket.CLOSED) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
this.socket.close(code, reason);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
this.notifyError(normalizeToError(error, "Failed to disconnect client."));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
on(event, handler) {
|
|
814
|
+
try {
|
|
815
|
+
if (event === "connect") {
|
|
816
|
+
this.connectHandlers.add(handler);
|
|
817
|
+
return this;
|
|
818
|
+
}
|
|
819
|
+
if (event === "disconnect") {
|
|
820
|
+
this.disconnectHandlers.add(handler);
|
|
821
|
+
return this;
|
|
822
|
+
}
|
|
823
|
+
if (event === READY_EVENT) {
|
|
824
|
+
this.readyHandlers.add(handler);
|
|
825
|
+
return this;
|
|
826
|
+
}
|
|
827
|
+
if (event === "error") {
|
|
828
|
+
this.errorHandlers.add(handler);
|
|
829
|
+
return this;
|
|
830
|
+
}
|
|
831
|
+
if (event === INTERNAL_HANDSHAKE_EVENT) {
|
|
832
|
+
throw new Error(`The event "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
|
|
833
|
+
}
|
|
834
|
+
const typedHandler = handler;
|
|
835
|
+
const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
836
|
+
listeners.add(typedHandler);
|
|
837
|
+
this.customEventHandlers.set(event, listeners);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
this.notifyError(
|
|
840
|
+
normalizeToError(error, "Failed to register client event handler.")
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
return this;
|
|
844
|
+
}
|
|
845
|
+
off(event, handler) {
|
|
846
|
+
try {
|
|
847
|
+
if (event === "connect") {
|
|
848
|
+
this.connectHandlers.delete(handler);
|
|
849
|
+
return this;
|
|
850
|
+
}
|
|
851
|
+
if (event === "disconnect") {
|
|
852
|
+
this.disconnectHandlers.delete(handler);
|
|
853
|
+
return this;
|
|
854
|
+
}
|
|
855
|
+
if (event === READY_EVENT) {
|
|
856
|
+
this.readyHandlers.delete(handler);
|
|
857
|
+
return this;
|
|
858
|
+
}
|
|
859
|
+
if (event === "error") {
|
|
860
|
+
this.errorHandlers.delete(handler);
|
|
861
|
+
return this;
|
|
862
|
+
}
|
|
863
|
+
if (event === INTERNAL_HANDSHAKE_EVENT) {
|
|
864
|
+
return this;
|
|
865
|
+
}
|
|
866
|
+
const listeners = this.customEventHandlers.get(event);
|
|
867
|
+
if (!listeners) {
|
|
868
|
+
return this;
|
|
869
|
+
}
|
|
870
|
+
listeners.delete(handler);
|
|
871
|
+
if (listeners.size === 0) {
|
|
872
|
+
this.customEventHandlers.delete(event);
|
|
873
|
+
}
|
|
874
|
+
} catch (error) {
|
|
875
|
+
this.notifyError(
|
|
876
|
+
normalizeToError(error, "Failed to remove client event handler.")
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
return this;
|
|
880
|
+
}
|
|
881
|
+
emit(event, data) {
|
|
882
|
+
try {
|
|
883
|
+
if (isReservedEmitEvent(event)) {
|
|
884
|
+
throw new Error(`The event "${event}" is reserved and cannot be emitted manually.`);
|
|
885
|
+
}
|
|
886
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
887
|
+
throw new Error("Client socket is not connected.");
|
|
888
|
+
}
|
|
889
|
+
const envelope = { event, data };
|
|
890
|
+
if (!this.isHandshakeReady()) {
|
|
891
|
+
this.pendingPayloadQueue.push(envelope);
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
this.sendEncryptedEnvelope(envelope);
|
|
895
|
+
return true;
|
|
896
|
+
} catch (error) {
|
|
897
|
+
this.notifyError(normalizeToError(error, "Failed to emit client event."));
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
resolveReconnectConfig(reconnectOptions) {
|
|
902
|
+
if (typeof reconnectOptions === "boolean") {
|
|
903
|
+
return {
|
|
904
|
+
enabled: reconnectOptions,
|
|
905
|
+
initialDelayMs: DEFAULT_RECONNECT_INITIAL_DELAY_MS,
|
|
906
|
+
maxDelayMs: DEFAULT_RECONNECT_MAX_DELAY_MS,
|
|
907
|
+
factor: DEFAULT_RECONNECT_FACTOR,
|
|
908
|
+
jitterRatio: DEFAULT_RECONNECT_JITTER_RATIO,
|
|
909
|
+
maxAttempts: null
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
const initialDelayMs = reconnectOptions?.initialDelayMs ?? DEFAULT_RECONNECT_INITIAL_DELAY_MS;
|
|
913
|
+
const maxDelayMs = reconnectOptions?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
|
|
914
|
+
const factor = reconnectOptions?.factor ?? DEFAULT_RECONNECT_FACTOR;
|
|
915
|
+
const jitterRatio = reconnectOptions?.jitterRatio ?? DEFAULT_RECONNECT_JITTER_RATIO;
|
|
916
|
+
const maxAttempts = reconnectOptions?.maxAttempts ?? null;
|
|
917
|
+
if (!Number.isFinite(initialDelayMs) || initialDelayMs < 0) {
|
|
918
|
+
throw new Error("Client reconnect initialDelayMs must be a non-negative number.");
|
|
919
|
+
}
|
|
920
|
+
if (!Number.isFinite(maxDelayMs) || maxDelayMs < 0) {
|
|
921
|
+
throw new Error("Client reconnect maxDelayMs must be a non-negative number.");
|
|
922
|
+
}
|
|
923
|
+
if (maxDelayMs < initialDelayMs) {
|
|
924
|
+
throw new Error("Client reconnect maxDelayMs must be greater than or equal to initialDelayMs.");
|
|
925
|
+
}
|
|
926
|
+
if (!Number.isFinite(factor) || factor < 1) {
|
|
927
|
+
throw new Error("Client reconnect factor must be greater than or equal to 1.");
|
|
928
|
+
}
|
|
929
|
+
if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) {
|
|
930
|
+
throw new Error("Client reconnect jitterRatio must be between 0 and 1.");
|
|
931
|
+
}
|
|
932
|
+
if (maxAttempts !== null && (!Number.isInteger(maxAttempts) || maxAttempts < 0)) {
|
|
933
|
+
throw new Error("Client reconnect maxAttempts must be a non-negative integer or null.");
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
enabled: reconnectOptions?.enabled ?? true,
|
|
937
|
+
initialDelayMs,
|
|
938
|
+
maxDelayMs,
|
|
939
|
+
factor,
|
|
940
|
+
jitterRatio,
|
|
941
|
+
maxAttempts
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
scheduleReconnect() {
|
|
945
|
+
if (!this.reconnectConfig.enabled || this.reconnectTimer) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (this.reconnectConfig.maxAttempts !== null && this.reconnectAttemptCount >= this.reconnectConfig.maxAttempts) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
this.reconnectAttemptCount += 1;
|
|
952
|
+
const delayMs = this.computeReconnectDelay(this.reconnectAttemptCount);
|
|
953
|
+
this.reconnectTimer = setTimeout(() => {
|
|
954
|
+
this.reconnectTimer = null;
|
|
955
|
+
this.connect();
|
|
956
|
+
}, delayMs);
|
|
957
|
+
this.reconnectTimer.unref?.();
|
|
958
|
+
}
|
|
959
|
+
computeReconnectDelay(attemptNumber) {
|
|
960
|
+
const exponentialDelay = Math.min(
|
|
961
|
+
this.reconnectConfig.maxDelayMs,
|
|
962
|
+
this.reconnectConfig.initialDelayMs * Math.pow(this.reconnectConfig.factor, Math.max(0, attemptNumber - 1))
|
|
963
|
+
);
|
|
964
|
+
if (this.reconnectConfig.jitterRatio === 0 || exponentialDelay === 0) {
|
|
965
|
+
return Math.round(exponentialDelay);
|
|
966
|
+
}
|
|
967
|
+
const jitterDelta = exponentialDelay * this.reconnectConfig.jitterRatio;
|
|
968
|
+
const jitterOffset = (Math.random() * 2 - 1) * jitterDelta;
|
|
969
|
+
return Math.max(0, Math.round(exponentialDelay + jitterOffset));
|
|
970
|
+
}
|
|
971
|
+
clearReconnectTimer() {
|
|
972
|
+
if (!this.reconnectTimer) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
clearTimeout(this.reconnectTimer);
|
|
976
|
+
this.reconnectTimer = null;
|
|
977
|
+
}
|
|
978
|
+
createSocket() {
|
|
979
|
+
if (this.options.protocols !== void 0) {
|
|
980
|
+
return new WebSocket(this.url, this.options.protocols, this.options.wsOptions);
|
|
981
|
+
}
|
|
982
|
+
if (this.options.wsOptions !== void 0) {
|
|
983
|
+
return new WebSocket(this.url, this.options.wsOptions);
|
|
984
|
+
}
|
|
985
|
+
return new WebSocket(this.url);
|
|
986
|
+
}
|
|
987
|
+
bindSocketEvents(socket) {
|
|
988
|
+
socket.on("open", () => {
|
|
989
|
+
this.clearReconnectTimer();
|
|
990
|
+
this.reconnectAttemptCount = 0;
|
|
991
|
+
this.sendInternalHandshake();
|
|
992
|
+
this.notifyConnect();
|
|
993
|
+
});
|
|
994
|
+
socket.on("message", (rawData) => {
|
|
995
|
+
this.handleIncomingMessage(rawData);
|
|
996
|
+
});
|
|
997
|
+
socket.on("close", (code, reason) => {
|
|
998
|
+
this.handleDisconnect(code, reason);
|
|
999
|
+
});
|
|
1000
|
+
socket.on("error", (error) => {
|
|
1001
|
+
this.notifyError(normalizeToError(error, "Client socket encountered an error."));
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
handleIncomingMessage(rawData) {
|
|
1005
|
+
try {
|
|
1006
|
+
let envelope = null;
|
|
1007
|
+
try {
|
|
1008
|
+
envelope = parseEnvelope(rawData);
|
|
1009
|
+
} catch {
|
|
1010
|
+
envelope = null;
|
|
1011
|
+
}
|
|
1012
|
+
if (envelope?.event === INTERNAL_HANDSHAKE_EVENT) {
|
|
1013
|
+
this.handleInternalHandshake(envelope.data);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (envelope !== null) {
|
|
1017
|
+
this.notifyError(
|
|
1018
|
+
new Error(`Plaintext event "${envelope.event}" was rejected on client.`)
|
|
1019
|
+
);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (!this.isHandshakeReady()) {
|
|
1023
|
+
this.notifyError(new Error("Encrypted payload was received before handshake completion."));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const encryptionKey = this.handshakeState?.encryptionKey;
|
|
1027
|
+
if (!encryptionKey) {
|
|
1028
|
+
this.notifyError(new Error("Missing encryption key for client payload decryption."));
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
let decryptedPayload;
|
|
1032
|
+
try {
|
|
1033
|
+
decryptedPayload = decryptSerializedEnvelope(rawData, encryptionKey);
|
|
1034
|
+
} catch {
|
|
1035
|
+
console.warn("Tampered data detected and dropped");
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const decryptedEnvelope = parseEnvelopeFromText(decryptedPayload);
|
|
1039
|
+
this.dispatchCustomEvent(decryptedEnvelope.event, decryptedEnvelope.data);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
this.notifyError(normalizeToError(error, "Failed to process incoming client message."));
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
handleDisconnect(code, reason) {
|
|
1045
|
+
try {
|
|
1046
|
+
this.socket = null;
|
|
1047
|
+
this.handshakeState = null;
|
|
1048
|
+
this.pendingPayloadQueue = [];
|
|
1049
|
+
const decodedReason = decodeCloseReason(reason);
|
|
1050
|
+
for (const handler of this.disconnectHandlers) {
|
|
1051
|
+
try {
|
|
1052
|
+
handler(code, decodedReason);
|
|
1053
|
+
} catch (handlerError) {
|
|
1054
|
+
this.notifyError(
|
|
1055
|
+
normalizeToError(handlerError, "Client disconnect handler failed.")
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!this.isManualDisconnectRequested) {
|
|
1060
|
+
this.scheduleReconnect();
|
|
1061
|
+
}
|
|
1062
|
+
this.isManualDisconnectRequested = false;
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
this.notifyError(normalizeToError(error, "Failed to handle client disconnect."));
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
dispatchCustomEvent(event, data) {
|
|
1068
|
+
const handlers = this.customEventHandlers.get(event);
|
|
1069
|
+
if (!handlers || handlers.size === 0) {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
for (const handler of handlers) {
|
|
1073
|
+
try {
|
|
1074
|
+
handler(data);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
this.notifyError(
|
|
1077
|
+
normalizeToError(error, `Client event handler failed for event ${event}.`)
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
notifyConnect() {
|
|
1083
|
+
for (const handler of this.connectHandlers) {
|
|
1084
|
+
try {
|
|
1085
|
+
handler();
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
this.notifyError(normalizeToError(error, "Client connect handler failed."));
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
notifyReady() {
|
|
1092
|
+
for (const handler of this.readyHandlers) {
|
|
1093
|
+
try {
|
|
1094
|
+
handler();
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
this.notifyError(normalizeToError(error, "Client ready handler failed."));
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
notifyError(error) {
|
|
1101
|
+
if (this.errorHandlers.size === 0) {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
for (const handler of this.errorHandlers) {
|
|
1105
|
+
try {
|
|
1106
|
+
handler(error);
|
|
1107
|
+
} catch {
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
sendEncryptedEnvelope(envelope) {
|
|
1112
|
+
try {
|
|
1113
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1114
|
+
throw new Error("Client socket is not connected.");
|
|
1115
|
+
}
|
|
1116
|
+
const encryptionKey = this.handshakeState?.encryptionKey;
|
|
1117
|
+
if (!encryptionKey) {
|
|
1118
|
+
throw new Error("Missing encryption key for client payload encryption.");
|
|
1119
|
+
}
|
|
1120
|
+
const encryptedPayload = encryptSerializedEnvelope(
|
|
1121
|
+
serializeEnvelope(envelope.event, envelope.data),
|
|
1122
|
+
encryptionKey
|
|
1123
|
+
);
|
|
1124
|
+
this.socket.send(encryptedPayload);
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
this.notifyError(normalizeToError(error, "Failed to send encrypted client payload."));
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
createClientHandshakeState() {
|
|
1130
|
+
const { ecdh, localPublicKey } = createEphemeralHandshakeState();
|
|
1131
|
+
return {
|
|
1132
|
+
ecdh,
|
|
1133
|
+
localPublicKey,
|
|
1134
|
+
isReady: false,
|
|
1135
|
+
sharedSecret: null,
|
|
1136
|
+
encryptionKey: null
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
sendInternalHandshake() {
|
|
1140
|
+
try {
|
|
1141
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (!this.handshakeState) {
|
|
1145
|
+
throw new Error("Missing client handshake state.");
|
|
1146
|
+
}
|
|
1147
|
+
this.socket.send(
|
|
1148
|
+
serializeEnvelope(INTERNAL_HANDSHAKE_EVENT, {
|
|
1149
|
+
publicKey: this.handshakeState.localPublicKey
|
|
1150
|
+
})
|
|
1151
|
+
);
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
this.notifyError(normalizeToError(error, "Failed to send client handshake payload."));
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
handleInternalHandshake(data) {
|
|
1157
|
+
try {
|
|
1158
|
+
const payload = parseHandshakePayload(data);
|
|
1159
|
+
if (!this.handshakeState) {
|
|
1160
|
+
throw new Error("Missing client handshake state.");
|
|
1161
|
+
}
|
|
1162
|
+
if (this.handshakeState.isReady) {
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const remotePublicKey = Buffer.from(payload.publicKey, "base64");
|
|
1166
|
+
const sharedSecret = this.handshakeState.ecdh.computeSecret(remotePublicKey);
|
|
1167
|
+
this.handshakeState.sharedSecret = sharedSecret;
|
|
1168
|
+
this.handshakeState.encryptionKey = deriveEncryptionKey(sharedSecret);
|
|
1169
|
+
this.handshakeState.isReady = true;
|
|
1170
|
+
this.flushPendingPayloadQueue();
|
|
1171
|
+
this.notifyReady();
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
this.notifyError(normalizeToError(error, "Failed to complete client handshake."));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
isHandshakeReady() {
|
|
1177
|
+
return this.handshakeState?.isReady ?? false;
|
|
1178
|
+
}
|
|
1179
|
+
flushPendingPayloadQueue() {
|
|
1180
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN || !this.isHandshakeReady()) {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
const pendingPayloads = this.pendingPayloadQueue;
|
|
1184
|
+
this.pendingPayloadQueue = [];
|
|
1185
|
+
for (const envelope of pendingPayloads) {
|
|
1186
|
+
this.sendEncryptedEnvelope(envelope);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
export { SecureClient, SecureServer };
|
|
1192
|
+
//# sourceMappingURL=index.js.map
|
|
1193
|
+
//# sourceMappingURL=index.js.map
|