@aegis-fluxion/core 0.7.2 → 0.9.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/README.md +127 -1
- package/dist/index.cjs +1312 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -1
- package/dist/index.d.ts +82 -1
- package/dist/index.js +1313 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { randomUUID,
|
|
1
|
+
import { randomUUID, randomBytes, createHmac, createDecipheriv, createCipheriv, createECDH, timingSafeEqual, createHash } from 'crypto';
|
|
2
|
+
import { PassThrough, Readable } from 'stream';
|
|
2
3
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
3
4
|
|
|
4
5
|
// src/index.ts
|
|
@@ -7,20 +8,31 @@ var DEFAULT_CLOSE_REASON = "";
|
|
|
7
8
|
var POLICY_VIOLATION_CLOSE_CODE = 1008;
|
|
8
9
|
var POLICY_VIOLATION_CLOSE_REASON = "Connection rejected by middleware.";
|
|
9
10
|
var INTERNAL_HANDSHAKE_EVENT = "__handshake";
|
|
11
|
+
var INTERNAL_SESSION_TICKET_EVENT = "__session:ticket";
|
|
10
12
|
var INTERNAL_RPC_REQUEST_EVENT = "__rpc:req";
|
|
11
13
|
var INTERNAL_RPC_RESPONSE_EVENT = "__rpc:res";
|
|
14
|
+
var INTERNAL_STREAM_FRAME_EVENT = "__stream:frame";
|
|
12
15
|
var READY_EVENT = "ready";
|
|
13
16
|
var HANDSHAKE_CURVE = "prime256v1";
|
|
17
|
+
var HANDSHAKE_PROTOCOL_VERSION = 1;
|
|
14
18
|
var ENCRYPTION_ALGORITHM = "aes-256-gcm";
|
|
15
19
|
var GCM_IV_LENGTH = 12;
|
|
16
20
|
var GCM_AUTH_TAG_LENGTH = 16;
|
|
17
21
|
var ENCRYPTION_KEY_LENGTH = 32;
|
|
18
22
|
var ENCRYPTED_PACKET_VERSION = 1;
|
|
19
23
|
var ENCRYPTED_PACKET_PREFIX_LENGTH = 1 + GCM_IV_LENGTH + GCM_AUTH_TAG_LENGTH;
|
|
24
|
+
var SESSION_TICKET_VERSION = 1;
|
|
20
25
|
var BINARY_PAYLOAD_MARKER = "__afxBinaryPayload";
|
|
21
26
|
var BINARY_PAYLOAD_VERSION = 1;
|
|
22
27
|
var DEFAULT_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
23
28
|
var DEFAULT_HEARTBEAT_TIMEOUT_MS = 15e3;
|
|
29
|
+
var DEFAULT_SESSION_RESUMPTION_ENABLED = true;
|
|
30
|
+
var DEFAULT_SESSION_TICKET_TTL_MS = 10 * 6e4;
|
|
31
|
+
var DEFAULT_SESSION_TICKET_MAX_CACHE_SIZE = 1e4;
|
|
32
|
+
var STREAM_FRAME_VERSION = 1;
|
|
33
|
+
var DEFAULT_STREAM_CHUNK_SIZE_BYTES = 64 * 1024;
|
|
34
|
+
var MAX_STREAM_CHUNK_SIZE_BYTES = 1024 * 1024;
|
|
35
|
+
var RESUMPTION_NONCE_LENGTH = 16;
|
|
24
36
|
var DEFAULT_RECONNECT_INITIAL_DELAY_MS = 250;
|
|
25
37
|
var DEFAULT_RECONNECT_MAX_DELAY_MS = 1e4;
|
|
26
38
|
var DEFAULT_RECONNECT_FACTOR = 2;
|
|
@@ -242,7 +254,7 @@ function decodeCloseReason(reason) {
|
|
|
242
254
|
return reason.toString("utf8");
|
|
243
255
|
}
|
|
244
256
|
function isReservedEmitEvent(event) {
|
|
245
|
-
return event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_RPC_REQUEST_EVENT || event === INTERNAL_RPC_RESPONSE_EVENT || event === READY_EVENT;
|
|
257
|
+
return event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT || event === INTERNAL_RPC_REQUEST_EVENT || event === INTERNAL_RPC_RESPONSE_EVENT || event === INTERNAL_STREAM_FRAME_EVENT || event === READY_EVENT;
|
|
246
258
|
}
|
|
247
259
|
function isPromiseLike(value) {
|
|
248
260
|
return typeof value === "object" && value !== null && "then" in value;
|
|
@@ -254,6 +266,240 @@ function normalizeRpcTimeout(timeoutMs) {
|
|
|
254
266
|
}
|
|
255
267
|
return resolvedTimeoutMs;
|
|
256
268
|
}
|
|
269
|
+
function normalizeStreamChunkSize(chunkSizeBytes) {
|
|
270
|
+
const resolvedChunkSize = chunkSizeBytes ?? DEFAULT_STREAM_CHUNK_SIZE_BYTES;
|
|
271
|
+
if (!Number.isInteger(resolvedChunkSize) || resolvedChunkSize <= 0) {
|
|
272
|
+
throw new Error("Stream chunkSizeBytes must be a positive integer.");
|
|
273
|
+
}
|
|
274
|
+
if (resolvedChunkSize > MAX_STREAM_CHUNK_SIZE_BYTES) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Stream chunkSizeBytes cannot exceed ${MAX_STREAM_CHUNK_SIZE_BYTES} bytes.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return resolvedChunkSize;
|
|
280
|
+
}
|
|
281
|
+
function resolveKnownStreamSourceSize(source, hint) {
|
|
282
|
+
if (hint !== void 0) {
|
|
283
|
+
if (!Number.isInteger(hint) || hint < 0) {
|
|
284
|
+
throw new Error("Stream totalBytes must be a non-negative integer.");
|
|
285
|
+
}
|
|
286
|
+
return hint;
|
|
287
|
+
}
|
|
288
|
+
if (Buffer.isBuffer(source)) {
|
|
289
|
+
return source.length;
|
|
290
|
+
}
|
|
291
|
+
if (source instanceof Uint8Array) {
|
|
292
|
+
return source.byteLength;
|
|
293
|
+
}
|
|
294
|
+
return void 0;
|
|
295
|
+
}
|
|
296
|
+
function normalizeChunkSourceValue(value) {
|
|
297
|
+
if (Buffer.isBuffer(value)) {
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
if (value instanceof Uint8Array) {
|
|
301
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
302
|
+
}
|
|
303
|
+
if (value instanceof ArrayBuffer) {
|
|
304
|
+
return Buffer.from(value);
|
|
305
|
+
}
|
|
306
|
+
if (typeof value === "string") {
|
|
307
|
+
return Buffer.from(value, "utf8");
|
|
308
|
+
}
|
|
309
|
+
throw new Error("Stream source yielded an unsupported chunk value.");
|
|
310
|
+
}
|
|
311
|
+
function isAsyncIterableValue(value) {
|
|
312
|
+
return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
|
|
313
|
+
}
|
|
314
|
+
function isReadableSource(value) {
|
|
315
|
+
return value instanceof Readable;
|
|
316
|
+
}
|
|
317
|
+
function splitChunkBuffer(chunk, chunkSizeBytes) {
|
|
318
|
+
if (chunk.length <= chunkSizeBytes) {
|
|
319
|
+
return [chunk];
|
|
320
|
+
}
|
|
321
|
+
const splitChunks = [];
|
|
322
|
+
for (let offset = 0; offset < chunk.length; offset += chunkSizeBytes) {
|
|
323
|
+
splitChunks.push(chunk.subarray(offset, offset + chunkSizeBytes));
|
|
324
|
+
}
|
|
325
|
+
return splitChunks;
|
|
326
|
+
}
|
|
327
|
+
async function* createChunkStreamIterator(source, chunkSizeBytes) {
|
|
328
|
+
if (Buffer.isBuffer(source)) {
|
|
329
|
+
yield* splitChunkBuffer(source, chunkSizeBytes);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (source instanceof Uint8Array) {
|
|
333
|
+
yield* splitChunkBuffer(
|
|
334
|
+
Buffer.from(source.buffer, source.byteOffset, source.byteLength),
|
|
335
|
+
chunkSizeBytes
|
|
336
|
+
);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (isReadableSource(source) || isAsyncIterableValue(source)) {
|
|
340
|
+
for await (const chunkValue of source) {
|
|
341
|
+
const normalizedChunk = normalizeChunkSourceValue(chunkValue);
|
|
342
|
+
if (normalizedChunk.length === 0) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
yield* splitChunkBuffer(normalizedChunk, chunkSizeBytes);
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
throw new Error("Unsupported stream source type.");
|
|
350
|
+
}
|
|
351
|
+
function parseStreamFramePayload(data) {
|
|
352
|
+
if (typeof data !== "object" || data === null) {
|
|
353
|
+
throw new Error("Invalid stream frame payload format.");
|
|
354
|
+
}
|
|
355
|
+
const payload = data;
|
|
356
|
+
if (payload.version !== STREAM_FRAME_VERSION) {
|
|
357
|
+
throw new Error(`Unsupported stream frame version: ${String(payload.version)}.`);
|
|
358
|
+
}
|
|
359
|
+
if (typeof payload.streamId !== "string" || payload.streamId.trim().length === 0) {
|
|
360
|
+
throw new Error("Stream frame streamId must be a non-empty string.");
|
|
361
|
+
}
|
|
362
|
+
if (payload.type === "start") {
|
|
363
|
+
if (typeof payload.event !== "string" || payload.event.trim().length === 0) {
|
|
364
|
+
throw new Error("Stream start frame event must be a non-empty string.");
|
|
365
|
+
}
|
|
366
|
+
if (payload.totalBytes !== void 0 && (!Number.isInteger(payload.totalBytes) || payload.totalBytes < 0)) {
|
|
367
|
+
throw new Error("Stream start frame totalBytes must be a non-negative integer.");
|
|
368
|
+
}
|
|
369
|
+
if (payload.metadata !== void 0 && !isPlainObject(payload.metadata)) {
|
|
370
|
+
throw new Error("Stream start frame metadata must be a plain object when provided.");
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
version: STREAM_FRAME_VERSION,
|
|
374
|
+
type: "start",
|
|
375
|
+
streamId: payload.streamId.trim(),
|
|
376
|
+
event: payload.event.trim(),
|
|
377
|
+
...payload.metadata ? { metadata: payload.metadata } : {},
|
|
378
|
+
...payload.totalBytes !== void 0 ? { totalBytes: payload.totalBytes } : {}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (payload.type === "chunk") {
|
|
382
|
+
const { index, byteLength } = payload;
|
|
383
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0) {
|
|
384
|
+
throw new Error("Stream chunk frame index must be a non-negative integer.");
|
|
385
|
+
}
|
|
386
|
+
if (typeof payload.payload !== "string" || payload.payload.length === 0) {
|
|
387
|
+
throw new Error("Stream chunk frame payload must be a non-empty base64 string.");
|
|
388
|
+
}
|
|
389
|
+
if (typeof byteLength !== "number" || !Number.isInteger(byteLength) || byteLength <= 0) {
|
|
390
|
+
throw new Error("Stream chunk frame byteLength must be a positive integer.");
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
version: STREAM_FRAME_VERSION,
|
|
394
|
+
type: "chunk",
|
|
395
|
+
streamId: payload.streamId.trim(),
|
|
396
|
+
index,
|
|
397
|
+
payload: payload.payload,
|
|
398
|
+
byteLength
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (payload.type === "end") {
|
|
402
|
+
const { chunkCount, totalBytes } = payload;
|
|
403
|
+
if (typeof chunkCount !== "number" || !Number.isInteger(chunkCount) || chunkCount < 0) {
|
|
404
|
+
throw new Error("Stream end frame chunkCount must be a non-negative integer.");
|
|
405
|
+
}
|
|
406
|
+
if (typeof totalBytes !== "number" || !Number.isInteger(totalBytes) || totalBytes < 0) {
|
|
407
|
+
throw new Error("Stream end frame totalBytes must be a non-negative integer.");
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
version: STREAM_FRAME_VERSION,
|
|
411
|
+
type: "end",
|
|
412
|
+
streamId: payload.streamId.trim(),
|
|
413
|
+
chunkCount,
|
|
414
|
+
totalBytes
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (payload.type === "abort") {
|
|
418
|
+
if (typeof payload.reason !== "string" || payload.reason.trim().length === 0) {
|
|
419
|
+
throw new Error("Stream abort frame reason must be a non-empty string.");
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
version: STREAM_FRAME_VERSION,
|
|
423
|
+
type: "abort",
|
|
424
|
+
streamId: payload.streamId.trim(),
|
|
425
|
+
reason: payload.reason.trim()
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
throw new Error("Unsupported stream frame type.");
|
|
429
|
+
}
|
|
430
|
+
async function transmitChunkedStreamFrames(event, source, options, sendFrame) {
|
|
431
|
+
const chunkSizeBytes = normalizeStreamChunkSize(options?.chunkSizeBytes);
|
|
432
|
+
const totalBytesHint = resolveKnownStreamSourceSize(source, options?.totalBytes);
|
|
433
|
+
if (options?.metadata !== void 0 && !isPlainObject(options.metadata)) {
|
|
434
|
+
throw new Error("Stream metadata must be a plain object when provided.");
|
|
435
|
+
}
|
|
436
|
+
if (options?.signal?.aborted) {
|
|
437
|
+
throw new Error("Stream transfer aborted before dispatch.");
|
|
438
|
+
}
|
|
439
|
+
const streamId = randomUUID();
|
|
440
|
+
let chunkCount = 0;
|
|
441
|
+
let totalBytes = 0;
|
|
442
|
+
await sendFrame({
|
|
443
|
+
version: STREAM_FRAME_VERSION,
|
|
444
|
+
type: "start",
|
|
445
|
+
streamId,
|
|
446
|
+
event,
|
|
447
|
+
...options?.metadata ? { metadata: options.metadata } : {},
|
|
448
|
+
...totalBytesHint !== void 0 ? { totalBytes: totalBytesHint } : {}
|
|
449
|
+
});
|
|
450
|
+
try {
|
|
451
|
+
for await (const chunkBuffer of createChunkStreamIterator(source, chunkSizeBytes)) {
|
|
452
|
+
if (options?.signal?.aborted) {
|
|
453
|
+
throw new Error("Stream transfer aborted by caller signal.");
|
|
454
|
+
}
|
|
455
|
+
if (chunkBuffer.length === 0) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
await sendFrame({
|
|
459
|
+
version: STREAM_FRAME_VERSION,
|
|
460
|
+
type: "chunk",
|
|
461
|
+
streamId,
|
|
462
|
+
index: chunkCount,
|
|
463
|
+
payload: chunkBuffer.toString("base64"),
|
|
464
|
+
byteLength: chunkBuffer.length
|
|
465
|
+
});
|
|
466
|
+
chunkCount += 1;
|
|
467
|
+
totalBytes += chunkBuffer.length;
|
|
468
|
+
}
|
|
469
|
+
if (totalBytesHint !== void 0 && totalBytes !== totalBytesHint) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
`Stream totalBytes mismatch. Expected ${totalBytesHint}, received ${totalBytes}.`
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
await sendFrame({
|
|
475
|
+
version: STREAM_FRAME_VERSION,
|
|
476
|
+
type: "end",
|
|
477
|
+
streamId,
|
|
478
|
+
chunkCount,
|
|
479
|
+
totalBytes
|
|
480
|
+
});
|
|
481
|
+
return {
|
|
482
|
+
streamId,
|
|
483
|
+
chunkCount,
|
|
484
|
+
totalBytes
|
|
485
|
+
};
|
|
486
|
+
} catch (error) {
|
|
487
|
+
const normalizedError = normalizeToError(
|
|
488
|
+
error,
|
|
489
|
+
`Chunked stream transfer failed for event "${event}".`
|
|
490
|
+
);
|
|
491
|
+
try {
|
|
492
|
+
await sendFrame({
|
|
493
|
+
version: STREAM_FRAME_VERSION,
|
|
494
|
+
type: "abort",
|
|
495
|
+
streamId,
|
|
496
|
+
reason: normalizedError.message
|
|
497
|
+
});
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
throw normalizedError;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
257
503
|
function parseRpcRequestPayload(data) {
|
|
258
504
|
if (typeof data !== "object" || data === null) {
|
|
259
505
|
throw new Error("Invalid RPC request payload format.");
|
|
@@ -333,16 +579,149 @@ function createEphemeralHandshakeState() {
|
|
|
333
579
|
localPublicKey: ecdh.getPublicKey("base64")
|
|
334
580
|
};
|
|
335
581
|
}
|
|
582
|
+
function decodeBase64ToBuffer(value, fieldName) {
|
|
583
|
+
if (typeof value !== "string") {
|
|
584
|
+
throw new Error(`${fieldName} must be a base64 string.`);
|
|
585
|
+
}
|
|
586
|
+
const normalizedValue = value.trim();
|
|
587
|
+
if (normalizedValue.length === 0) {
|
|
588
|
+
throw new Error(`${fieldName} must be a non-empty base64 string.`);
|
|
589
|
+
}
|
|
590
|
+
const decodedBuffer = Buffer.from(normalizedValue, "base64");
|
|
591
|
+
if (decodedBuffer.length === 0) {
|
|
592
|
+
throw new Error(`${fieldName} could not be decoded from base64.`);
|
|
593
|
+
}
|
|
594
|
+
const canonicalInput = normalizedValue.replace(/=+$/u, "");
|
|
595
|
+
const canonicalDecoded = decodedBuffer.toString("base64").replace(/=+$/u, "");
|
|
596
|
+
if (canonicalInput !== canonicalDecoded) {
|
|
597
|
+
throw new Error(`${fieldName} is not valid base64 content.`);
|
|
598
|
+
}
|
|
599
|
+
return decodedBuffer;
|
|
600
|
+
}
|
|
601
|
+
function equalsConstantTime(left, right) {
|
|
602
|
+
if (left.length !== right.length) {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
return timingSafeEqual(left, right);
|
|
606
|
+
}
|
|
607
|
+
function createResumeClientProof(sessionSecret, sessionId, clientNonce) {
|
|
608
|
+
return createHmac("sha256", sessionSecret).update("afx-resume-client-proof:v1").update(sessionId).update(clientNonce).digest();
|
|
609
|
+
}
|
|
610
|
+
function createResumeServerProof(resumedKey, sessionId, clientNonce) {
|
|
611
|
+
return createHmac("sha256", resumedKey).update("afx-resume-server-proof:v1").update(sessionId).update(clientNonce).digest();
|
|
612
|
+
}
|
|
613
|
+
function deriveSessionTicketSecret(baseKey) {
|
|
614
|
+
return createHmac("sha256", baseKey).update("afx-session-ticket:v1").digest();
|
|
615
|
+
}
|
|
616
|
+
function deriveResumedEncryptionKey(sessionSecret, clientNonce) {
|
|
617
|
+
const derivedKey = createHash("sha256").update("afx-resume-encryption-key:v1").update(sessionSecret).update(clientNonce).digest();
|
|
618
|
+
if (derivedKey.length !== ENCRYPTION_KEY_LENGTH) {
|
|
619
|
+
throw new Error("Failed to derive a valid resumed AES-256 key.");
|
|
620
|
+
}
|
|
621
|
+
return derivedKey;
|
|
622
|
+
}
|
|
336
623
|
function parseHandshakePayload(data) {
|
|
337
624
|
if (typeof data !== "object" || data === null) {
|
|
338
625
|
throw new Error("Invalid handshake payload format.");
|
|
339
626
|
}
|
|
340
627
|
const payload = data;
|
|
341
|
-
if (typeof payload.
|
|
342
|
-
|
|
628
|
+
if (typeof payload.type !== "string") {
|
|
629
|
+
if (typeof payload.publicKey === "string" && payload.publicKey.length > 0) {
|
|
630
|
+
return {
|
|
631
|
+
type: "hello",
|
|
632
|
+
protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
|
|
633
|
+
publicKey: payload.publicKey
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
throw new Error("Handshake payload must include a valid type.");
|
|
637
|
+
}
|
|
638
|
+
const protocolVersion = payload.protocolVersion === void 0 ? HANDSHAKE_PROTOCOL_VERSION : payload.protocolVersion;
|
|
639
|
+
if (protocolVersion !== HANDSHAKE_PROTOCOL_VERSION) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Unsupported handshake protocol version: ${String(protocolVersion)}.`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
if (payload.type === "hello") {
|
|
645
|
+
if (typeof payload.publicKey !== "string" || payload.publicKey.length === 0) {
|
|
646
|
+
throw new Error("Handshake hello payload must include a non-empty public key.");
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
type: "hello",
|
|
650
|
+
protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
|
|
651
|
+
publicKey: payload.publicKey
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (payload.type === "resume") {
|
|
655
|
+
if (typeof payload.sessionId !== "string" || payload.sessionId.trim().length === 0) {
|
|
656
|
+
throw new Error("Handshake resume payload must include a non-empty sessionId.");
|
|
657
|
+
}
|
|
658
|
+
if (typeof payload.clientNonce !== "string" || payload.clientNonce.length === 0) {
|
|
659
|
+
throw new Error("Handshake resume payload must include a non-empty clientNonce.");
|
|
660
|
+
}
|
|
661
|
+
if (typeof payload.clientProof !== "string" || payload.clientProof.length === 0) {
|
|
662
|
+
throw new Error("Handshake resume payload must include a non-empty clientProof.");
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
type: "resume",
|
|
666
|
+
protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
|
|
667
|
+
sessionId: payload.sessionId.trim(),
|
|
668
|
+
clientNonce: payload.clientNonce,
|
|
669
|
+
clientProof: payload.clientProof
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (payload.type === "resume-ack") {
|
|
673
|
+
if (typeof payload.ok !== "boolean") {
|
|
674
|
+
throw new Error("Handshake resume-ack payload must include boolean ok.");
|
|
675
|
+
}
|
|
676
|
+
const normalizedPayload = {
|
|
677
|
+
type: "resume-ack",
|
|
678
|
+
protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
|
|
679
|
+
ok: payload.ok
|
|
680
|
+
};
|
|
681
|
+
if (typeof payload.sessionId === "string" && payload.sessionId.trim().length > 0) {
|
|
682
|
+
normalizedPayload.sessionId = payload.sessionId.trim();
|
|
683
|
+
}
|
|
684
|
+
if (typeof payload.serverProof === "string" && payload.serverProof.length > 0) {
|
|
685
|
+
normalizedPayload.serverProof = payload.serverProof;
|
|
686
|
+
}
|
|
687
|
+
if (typeof payload.reason === "string" && payload.reason.trim().length > 0) {
|
|
688
|
+
normalizedPayload.reason = payload.reason.trim();
|
|
689
|
+
}
|
|
690
|
+
return normalizedPayload;
|
|
691
|
+
}
|
|
692
|
+
throw new Error(`Unsupported handshake payload type: ${payload.type}.`);
|
|
693
|
+
}
|
|
694
|
+
function parseSessionTicketPayload(data) {
|
|
695
|
+
if (typeof data !== "object" || data === null) {
|
|
696
|
+
throw new Error("Invalid session ticket payload format.");
|
|
697
|
+
}
|
|
698
|
+
const payload = data;
|
|
699
|
+
if (payload.version !== SESSION_TICKET_VERSION) {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`Unsupported session ticket payload version: ${String(payload.version)}.`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
if (typeof payload.sessionId !== "string" || payload.sessionId.trim().length === 0) {
|
|
705
|
+
throw new Error("Session ticket payload must include a non-empty sessionId.");
|
|
706
|
+
}
|
|
707
|
+
if (typeof payload.secret !== "string" || payload.secret.length === 0) {
|
|
708
|
+
throw new Error("Session ticket payload must include a non-empty secret.");
|
|
709
|
+
}
|
|
710
|
+
if (typeof payload.issuedAt !== "number" || !Number.isFinite(payload.issuedAt)) {
|
|
711
|
+
throw new Error("Session ticket payload issuedAt must be a finite number.");
|
|
712
|
+
}
|
|
713
|
+
if (typeof payload.expiresAt !== "number" || !Number.isFinite(payload.expiresAt)) {
|
|
714
|
+
throw new Error("Session ticket payload expiresAt must be a finite number.");
|
|
715
|
+
}
|
|
716
|
+
if (payload.expiresAt <= payload.issuedAt) {
|
|
717
|
+
throw new Error("Session ticket payload expiresAt must be greater than issuedAt.");
|
|
343
718
|
}
|
|
344
719
|
return {
|
|
345
|
-
|
|
720
|
+
version: SESSION_TICKET_VERSION,
|
|
721
|
+
sessionId: payload.sessionId.trim(),
|
|
722
|
+
secret: payload.secret,
|
|
723
|
+
issuedAt: payload.issuedAt,
|
|
724
|
+
expiresAt: payload.expiresAt
|
|
346
725
|
};
|
|
347
726
|
}
|
|
348
727
|
function deriveEncryptionKey(sharedSecret) {
|
|
@@ -412,10 +791,12 @@ var SecureServer = class {
|
|
|
412
791
|
adapter = null;
|
|
413
792
|
heartbeatConfig;
|
|
414
793
|
rateLimitConfig;
|
|
794
|
+
sessionResumptionConfig;
|
|
415
795
|
heartbeatIntervalHandle = null;
|
|
416
796
|
clientsById = /* @__PURE__ */ new Map();
|
|
417
797
|
clientIdBySocket = /* @__PURE__ */ new Map();
|
|
418
798
|
customEventHandlers = /* @__PURE__ */ new Map();
|
|
799
|
+
streamEventHandlers = /* @__PURE__ */ new Map();
|
|
419
800
|
connectionHandlers = /* @__PURE__ */ new Set();
|
|
420
801
|
disconnectHandlers = /* @__PURE__ */ new Set();
|
|
421
802
|
readyHandlers = /* @__PURE__ */ new Set();
|
|
@@ -426,6 +807,7 @@ var SecureServer = class {
|
|
|
426
807
|
sharedSecretBySocket = /* @__PURE__ */ new WeakMap();
|
|
427
808
|
encryptionKeyBySocket = /* @__PURE__ */ new WeakMap();
|
|
428
809
|
pendingPayloadsBySocket = /* @__PURE__ */ new WeakMap();
|
|
810
|
+
incomingStreamsBySocket = /* @__PURE__ */ new WeakMap();
|
|
429
811
|
pendingRpcRequestsBySocket = /* @__PURE__ */ new WeakMap();
|
|
430
812
|
heartbeatStateBySocket = /* @__PURE__ */ new WeakMap();
|
|
431
813
|
roomMembersByName = /* @__PURE__ */ new Map();
|
|
@@ -433,10 +815,12 @@ var SecureServer = class {
|
|
|
433
815
|
clientIpByClientId = /* @__PURE__ */ new Map();
|
|
434
816
|
rateLimitBucketsByClientId = /* @__PURE__ */ new Map();
|
|
435
817
|
rateLimitBucketsByIp = /* @__PURE__ */ new Map();
|
|
818
|
+
sessionTicketStore = /* @__PURE__ */ new Map();
|
|
436
819
|
constructor(options) {
|
|
437
|
-
const { heartbeat, rateLimit, adapter, ...socketServerOptions } = options;
|
|
820
|
+
const { heartbeat, rateLimit, sessionResumption, adapter, ...socketServerOptions } = options;
|
|
438
821
|
this.heartbeatConfig = this.resolveHeartbeatConfig(heartbeat);
|
|
439
822
|
this.rateLimitConfig = this.resolveRateLimitConfig(rateLimit);
|
|
823
|
+
this.sessionResumptionConfig = this.resolveSessionResumptionConfig(sessionResumption);
|
|
440
824
|
this.socketServer = new WebSocketServer(socketServerOptions);
|
|
441
825
|
this.bindSocketServerEvents();
|
|
442
826
|
this.startHeartbeatLoop();
|
|
@@ -522,8 +906,8 @@ var SecureServer = class {
|
|
|
522
906
|
this.errorHandlers.add(handler);
|
|
523
907
|
return this;
|
|
524
908
|
}
|
|
525
|
-
if (event === INTERNAL_HANDSHAKE_EVENT) {
|
|
526
|
-
throw new Error(`The event "${
|
|
909
|
+
if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
|
|
910
|
+
throw new Error(`The event "${event}" is reserved for internal use.`);
|
|
527
911
|
}
|
|
528
912
|
const typedHandler = handler;
|
|
529
913
|
const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
@@ -554,7 +938,7 @@ var SecureServer = class {
|
|
|
554
938
|
this.errorHandlers.delete(handler);
|
|
555
939
|
return this;
|
|
556
940
|
}
|
|
557
|
-
if (event === INTERNAL_HANDSHAKE_EVENT) {
|
|
941
|
+
if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
|
|
558
942
|
return this;
|
|
559
943
|
}
|
|
560
944
|
const listeners = this.customEventHandlers.get(event);
|
|
@@ -572,6 +956,38 @@ var SecureServer = class {
|
|
|
572
956
|
}
|
|
573
957
|
return this;
|
|
574
958
|
}
|
|
959
|
+
onStream(event, handler) {
|
|
960
|
+
try {
|
|
961
|
+
if (isReservedEmitEvent(event)) {
|
|
962
|
+
throw new Error(`The event "${event}" is reserved and cannot be used as a stream event.`);
|
|
963
|
+
}
|
|
964
|
+
const listeners = this.streamEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
965
|
+
listeners.add(handler);
|
|
966
|
+
this.streamEventHandlers.set(event, listeners);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
this.notifyError(
|
|
969
|
+
normalizeToError(error, "Failed to register server stream handler.")
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
return this;
|
|
973
|
+
}
|
|
974
|
+
offStream(event, handler) {
|
|
975
|
+
try {
|
|
976
|
+
const listeners = this.streamEventHandlers.get(event);
|
|
977
|
+
if (!listeners) {
|
|
978
|
+
return this;
|
|
979
|
+
}
|
|
980
|
+
listeners.delete(handler);
|
|
981
|
+
if (listeners.size === 0) {
|
|
982
|
+
this.streamEventHandlers.delete(event);
|
|
983
|
+
}
|
|
984
|
+
} catch (error) {
|
|
985
|
+
this.notifyError(
|
|
986
|
+
normalizeToError(error, "Failed to remove server stream handler.")
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
return this;
|
|
990
|
+
}
|
|
575
991
|
use(middleware) {
|
|
576
992
|
try {
|
|
577
993
|
if (typeof middleware !== "function") {
|
|
@@ -647,6 +1063,40 @@ var SecureServer = class {
|
|
|
647
1063
|
return false;
|
|
648
1064
|
}
|
|
649
1065
|
}
|
|
1066
|
+
async emitStreamTo(clientId, event, source, options) {
|
|
1067
|
+
try {
|
|
1068
|
+
if (isReservedEmitEvent(event)) {
|
|
1069
|
+
throw new Error(`The event "${event}" is reserved and cannot be emitted manually.`);
|
|
1070
|
+
}
|
|
1071
|
+
const client = this.clientsById.get(clientId);
|
|
1072
|
+
if (!client) {
|
|
1073
|
+
throw new Error(`Client with id ${clientId} was not found.`);
|
|
1074
|
+
}
|
|
1075
|
+
if (!this.isClientHandshakeReady(client.socket)) {
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
`Cannot stream event "${event}" before secure handshake completion for client ${client.id}.`
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
return await transmitChunkedStreamFrames(
|
|
1081
|
+
event,
|
|
1082
|
+
source,
|
|
1083
|
+
options,
|
|
1084
|
+
async (framePayload) => {
|
|
1085
|
+
await this.sendEncryptedEnvelope(client.socket, {
|
|
1086
|
+
event: INTERNAL_STREAM_FRAME_EVENT,
|
|
1087
|
+
data: framePayload
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
const normalizedError = normalizeToError(
|
|
1093
|
+
error,
|
|
1094
|
+
`Failed to emit chunked stream event "${event}" to client ${clientId}.`
|
|
1095
|
+
);
|
|
1096
|
+
this.notifyError(normalizedError);
|
|
1097
|
+
throw normalizedError;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
650
1100
|
to(room) {
|
|
651
1101
|
const normalizedRoom = this.normalizeRoomName(room);
|
|
652
1102
|
return {
|
|
@@ -679,6 +1129,10 @@ var SecureServer = class {
|
|
|
679
1129
|
client.socket,
|
|
680
1130
|
new Error("Server closed before ACK response was received.")
|
|
681
1131
|
);
|
|
1132
|
+
this.cleanupIncomingStreamsForSocket(
|
|
1133
|
+
client.socket,
|
|
1134
|
+
"Server closed before stream transfer completed."
|
|
1135
|
+
);
|
|
682
1136
|
this.middlewareMetadataBySocket.delete(client.socket);
|
|
683
1137
|
if (client.socket.readyState === WebSocket.OPEN || client.socket.readyState === WebSocket.CONNECTING) {
|
|
684
1138
|
client.socket.close(code, reason);
|
|
@@ -687,6 +1141,7 @@ var SecureServer = class {
|
|
|
687
1141
|
this.rateLimitBucketsByClientId.clear();
|
|
688
1142
|
this.rateLimitBucketsByIp.clear();
|
|
689
1143
|
this.clientIpByClientId.clear();
|
|
1144
|
+
this.sessionTicketStore.clear();
|
|
690
1145
|
this.socketServer.close();
|
|
691
1146
|
} catch (error) {
|
|
692
1147
|
this.notifyError(normalizeToError(error, "Failed to close server."));
|
|
@@ -763,6 +1218,94 @@ var SecureServer = class {
|
|
|
763
1218
|
disconnectReason
|
|
764
1219
|
};
|
|
765
1220
|
}
|
|
1221
|
+
resolveSessionResumptionConfig(sessionResumptionOptions) {
|
|
1222
|
+
const ticketTtlMs = sessionResumptionOptions?.ticketTtlMs ?? DEFAULT_SESSION_TICKET_TTL_MS;
|
|
1223
|
+
const maxCachedTickets = sessionResumptionOptions?.maxCachedTickets ?? DEFAULT_SESSION_TICKET_MAX_CACHE_SIZE;
|
|
1224
|
+
if (!Number.isFinite(ticketTtlMs) || ticketTtlMs <= 0) {
|
|
1225
|
+
throw new Error(
|
|
1226
|
+
"Server sessionResumption ticketTtlMs must be a positive number."
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
if (!Number.isInteger(maxCachedTickets) || maxCachedTickets <= 0) {
|
|
1230
|
+
throw new Error(
|
|
1231
|
+
"Server sessionResumption maxCachedTickets must be a positive integer."
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
return {
|
|
1235
|
+
enabled: sessionResumptionOptions?.enabled ?? DEFAULT_SESSION_RESUMPTION_ENABLED,
|
|
1236
|
+
ticketTtlMs,
|
|
1237
|
+
maxCachedTickets
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
pruneExpiredSessionTickets(now) {
|
|
1241
|
+
for (const [sessionId, ticketRecord] of this.sessionTicketStore.entries()) {
|
|
1242
|
+
if (ticketRecord.expiresAt <= now) {
|
|
1243
|
+
this.sessionTicketStore.delete(sessionId);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
evictSessionTicketsIfNeeded() {
|
|
1248
|
+
while (this.sessionTicketStore.size > this.sessionResumptionConfig.maxCachedTickets) {
|
|
1249
|
+
let oldestSessionId = null;
|
|
1250
|
+
let oldestIssuedAt = Number.POSITIVE_INFINITY;
|
|
1251
|
+
for (const [sessionId, ticketRecord] of this.sessionTicketStore.entries()) {
|
|
1252
|
+
if (ticketRecord.issuedAt < oldestIssuedAt) {
|
|
1253
|
+
oldestIssuedAt = ticketRecord.issuedAt;
|
|
1254
|
+
oldestSessionId = sessionId;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (!oldestSessionId) {
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
this.sessionTicketStore.delete(oldestSessionId);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
getSessionTicket(sessionId) {
|
|
1264
|
+
const now = Date.now();
|
|
1265
|
+
this.pruneExpiredSessionTickets(now);
|
|
1266
|
+
const ticketRecord = this.sessionTicketStore.get(sessionId);
|
|
1267
|
+
if (!ticketRecord) {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
if (ticketRecord.expiresAt <= now) {
|
|
1271
|
+
this.sessionTicketStore.delete(sessionId);
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
return ticketRecord;
|
|
1275
|
+
}
|
|
1276
|
+
issueSessionTicket(socket, baseKey) {
|
|
1277
|
+
if (!this.sessionResumptionConfig.enabled) {
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const now = Date.now();
|
|
1281
|
+
this.pruneExpiredSessionTickets(now);
|
|
1282
|
+
const sessionId = randomUUID();
|
|
1283
|
+
const sessionSecret = deriveSessionTicketSecret(baseKey);
|
|
1284
|
+
const expiresAt = now + this.sessionResumptionConfig.ticketTtlMs;
|
|
1285
|
+
const ticketRecord = {
|
|
1286
|
+
sessionId,
|
|
1287
|
+
secret: sessionSecret,
|
|
1288
|
+
issuedAt: now,
|
|
1289
|
+
expiresAt
|
|
1290
|
+
};
|
|
1291
|
+
this.sessionTicketStore.set(sessionId, ticketRecord);
|
|
1292
|
+
this.evictSessionTicketsIfNeeded();
|
|
1293
|
+
const ticketPayload = {
|
|
1294
|
+
version: SESSION_TICKET_VERSION,
|
|
1295
|
+
sessionId,
|
|
1296
|
+
secret: sessionSecret.toString("base64"),
|
|
1297
|
+
issuedAt: now,
|
|
1298
|
+
expiresAt
|
|
1299
|
+
};
|
|
1300
|
+
void this.sendOrQueuePayload(socket, {
|
|
1301
|
+
event: INTERNAL_SESSION_TICKET_EVENT,
|
|
1302
|
+
data: ticketPayload
|
|
1303
|
+
}).catch((error) => {
|
|
1304
|
+
this.notifyError(
|
|
1305
|
+
normalizeToError(error, "Failed to deliver secure session ticket.")
|
|
1306
|
+
);
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
766
1309
|
createRateLimitBucket(now) {
|
|
767
1310
|
return {
|
|
768
1311
|
windowStartedAt: now,
|
|
@@ -1133,6 +1676,18 @@ var SecureServer = class {
|
|
|
1133
1676
|
await this.handleRpcRequest(client, decryptedEnvelope.data);
|
|
1134
1677
|
return;
|
|
1135
1678
|
}
|
|
1679
|
+
if (decryptedEnvelope.event === INTERNAL_STREAM_FRAME_EVENT) {
|
|
1680
|
+
this.handleIncomingStreamFrame(client, decryptedEnvelope.data);
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
if (decryptedEnvelope.event === INTERNAL_SESSION_TICKET_EVENT) {
|
|
1684
|
+
this.notifyError(
|
|
1685
|
+
new Error(
|
|
1686
|
+
`Client ${client.id} attempted to send reserved internal session ticket event.`
|
|
1687
|
+
)
|
|
1688
|
+
);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1136
1691
|
const interceptedData = await this.applyMessageMiddleware(
|
|
1137
1692
|
"incoming",
|
|
1138
1693
|
client,
|
|
@@ -1166,6 +1721,10 @@ var SecureServer = class {
|
|
|
1166
1721
|
this.pendingRpcRequestsBySocket.delete(client.socket);
|
|
1167
1722
|
this.heartbeatStateBySocket.delete(client.socket);
|
|
1168
1723
|
this.middlewareMetadataBySocket.delete(client.socket);
|
|
1724
|
+
this.cleanupIncomingStreamsForSocket(
|
|
1725
|
+
client.socket,
|
|
1726
|
+
`Client ${client.id} disconnected before stream transfer completed.`
|
|
1727
|
+
);
|
|
1169
1728
|
const decodedReason = decodeCloseReason(reason);
|
|
1170
1729
|
for (const handler of this.disconnectHandlers) {
|
|
1171
1730
|
try {
|
|
@@ -1211,6 +1770,197 @@ var SecureServer = class {
|
|
|
1211
1770
|
}
|
|
1212
1771
|
}
|
|
1213
1772
|
}
|
|
1773
|
+
getOrCreateIncomingServerStreams(socket) {
|
|
1774
|
+
const existingStreams = this.incomingStreamsBySocket.get(socket);
|
|
1775
|
+
if (existingStreams) {
|
|
1776
|
+
return existingStreams;
|
|
1777
|
+
}
|
|
1778
|
+
const streamMap = /* @__PURE__ */ new Map();
|
|
1779
|
+
this.incomingStreamsBySocket.set(socket, streamMap);
|
|
1780
|
+
return streamMap;
|
|
1781
|
+
}
|
|
1782
|
+
cleanupIncomingStreamsForSocket(socket, reason) {
|
|
1783
|
+
const streamMap = this.incomingStreamsBySocket.get(socket);
|
|
1784
|
+
if (!streamMap) {
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
for (const streamState of streamMap.values()) {
|
|
1788
|
+
streamState.stream.destroy(new Error(reason));
|
|
1789
|
+
}
|
|
1790
|
+
streamMap.clear();
|
|
1791
|
+
this.incomingStreamsBySocket.delete(socket);
|
|
1792
|
+
}
|
|
1793
|
+
abortIncomingServerStream(socket, streamId, reason) {
|
|
1794
|
+
const streamMap = this.incomingStreamsBySocket.get(socket);
|
|
1795
|
+
if (!streamMap) {
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const streamState = streamMap.get(streamId);
|
|
1799
|
+
if (!streamState) {
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
streamState.stream.destroy(new Error(reason));
|
|
1803
|
+
streamMap.delete(streamId);
|
|
1804
|
+
if (streamMap.size === 0) {
|
|
1805
|
+
this.incomingStreamsBySocket.delete(socket);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
dispatchServerStreamEvent(event, stream, info, client) {
|
|
1809
|
+
const handlers = this.streamEventHandlers.get(event);
|
|
1810
|
+
if (!handlers || handlers.size === 0) {
|
|
1811
|
+
stream.resume();
|
|
1812
|
+
this.notifyError(
|
|
1813
|
+
new Error(
|
|
1814
|
+
`No stream handler is registered for event "${event}" on server client ${client.id}.`
|
|
1815
|
+
)
|
|
1816
|
+
);
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
for (const handler of handlers) {
|
|
1820
|
+
try {
|
|
1821
|
+
const handlerResult = handler(stream, info, client);
|
|
1822
|
+
if (isPromiseLike(handlerResult)) {
|
|
1823
|
+
void Promise.resolve(handlerResult).catch((error) => {
|
|
1824
|
+
this.notifyError(
|
|
1825
|
+
normalizeToError(
|
|
1826
|
+
error,
|
|
1827
|
+
`Server stream handler failed for event ${event}.`
|
|
1828
|
+
)
|
|
1829
|
+
);
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
this.notifyError(
|
|
1834
|
+
normalizeToError(
|
|
1835
|
+
error,
|
|
1836
|
+
`Server stream handler failed for event ${event}.`
|
|
1837
|
+
)
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
handleIncomingStreamStartFrame(client, framePayload) {
|
|
1843
|
+
if (isReservedEmitEvent(framePayload.event)) {
|
|
1844
|
+
throw new Error(
|
|
1845
|
+
`Reserved event "${framePayload.event}" cannot be used for stream transport.`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
const incomingStreams = this.getOrCreateIncomingServerStreams(client.socket);
|
|
1849
|
+
if (incomingStreams.has(framePayload.streamId)) {
|
|
1850
|
+
throw new Error(
|
|
1851
|
+
`Stream ${framePayload.streamId} already exists for client ${client.id}.`
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
const stream = new PassThrough();
|
|
1855
|
+
const streamInfo = {
|
|
1856
|
+
streamId: framePayload.streamId,
|
|
1857
|
+
event: framePayload.event,
|
|
1858
|
+
startedAt: Date.now(),
|
|
1859
|
+
...framePayload.metadata !== void 0 ? { metadata: framePayload.metadata } : {},
|
|
1860
|
+
...framePayload.totalBytes !== void 0 ? { totalBytes: framePayload.totalBytes } : {}
|
|
1861
|
+
};
|
|
1862
|
+
incomingStreams.set(framePayload.streamId, {
|
|
1863
|
+
info: streamInfo,
|
|
1864
|
+
stream,
|
|
1865
|
+
expectedChunkIndex: 0,
|
|
1866
|
+
receivedBytes: 0
|
|
1867
|
+
});
|
|
1868
|
+
this.dispatchServerStreamEvent(framePayload.event, stream, streamInfo, client);
|
|
1869
|
+
}
|
|
1870
|
+
handleIncomingStreamChunkFrame(client, framePayload) {
|
|
1871
|
+
const incomingStreams = this.incomingStreamsBySocket.get(client.socket);
|
|
1872
|
+
const streamState = incomingStreams?.get(framePayload.streamId);
|
|
1873
|
+
if (!incomingStreams || !streamState) {
|
|
1874
|
+
throw new Error(
|
|
1875
|
+
`Stream ${framePayload.streamId} is unknown for client ${client.id}.`
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
if (framePayload.index !== streamState.expectedChunkIndex) {
|
|
1879
|
+
throw new Error(
|
|
1880
|
+
`Out-of-order chunk index for stream ${framePayload.streamId}. Expected ${streamState.expectedChunkIndex}, received ${framePayload.index}.`
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
const chunkBuffer = decodeBase64ToBuffer(
|
|
1884
|
+
framePayload.payload,
|
|
1885
|
+
`Stream chunk payload (${framePayload.streamId})`
|
|
1886
|
+
);
|
|
1887
|
+
if (chunkBuffer.length !== framePayload.byteLength) {
|
|
1888
|
+
throw new Error(
|
|
1889
|
+
`Stream ${framePayload.streamId} byteLength mismatch. Expected ${framePayload.byteLength}, received ${chunkBuffer.length}.`
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
streamState.expectedChunkIndex += 1;
|
|
1893
|
+
streamState.receivedBytes += chunkBuffer.length;
|
|
1894
|
+
streamState.stream.write(chunkBuffer);
|
|
1895
|
+
}
|
|
1896
|
+
handleIncomingStreamEndFrame(client, framePayload) {
|
|
1897
|
+
const incomingStreams = this.incomingStreamsBySocket.get(client.socket);
|
|
1898
|
+
const streamState = incomingStreams?.get(framePayload.streamId);
|
|
1899
|
+
if (!incomingStreams || !streamState) {
|
|
1900
|
+
throw new Error(
|
|
1901
|
+
`Stream ${framePayload.streamId} is unknown for client ${client.id}.`
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
if (framePayload.chunkCount !== streamState.expectedChunkIndex) {
|
|
1905
|
+
throw new Error(
|
|
1906
|
+
`Stream ${framePayload.streamId} chunkCount mismatch. Expected ${streamState.expectedChunkIndex}, received ${framePayload.chunkCount}.`
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
if (framePayload.totalBytes !== streamState.receivedBytes) {
|
|
1910
|
+
throw new Error(
|
|
1911
|
+
`Stream ${framePayload.streamId} totalBytes mismatch. Expected ${streamState.receivedBytes}, received ${framePayload.totalBytes}.`
|
|
1912
|
+
);
|
|
1913
|
+
}
|
|
1914
|
+
if (streamState.info.totalBytes !== void 0 && streamState.info.totalBytes !== streamState.receivedBytes) {
|
|
1915
|
+
throw new Error(
|
|
1916
|
+
`Stream ${framePayload.streamId} violated announced totalBytes (${streamState.info.totalBytes}).`
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
streamState.stream.end();
|
|
1920
|
+
incomingStreams.delete(framePayload.streamId);
|
|
1921
|
+
if (incomingStreams.size === 0) {
|
|
1922
|
+
this.incomingStreamsBySocket.delete(client.socket);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
handleIncomingStreamAbortFrame(client, framePayload) {
|
|
1926
|
+
this.abortIncomingServerStream(
|
|
1927
|
+
client.socket,
|
|
1928
|
+
framePayload.streamId,
|
|
1929
|
+
framePayload.reason
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
handleIncomingStreamFrame(client, data) {
|
|
1933
|
+
let framePayload = null;
|
|
1934
|
+
try {
|
|
1935
|
+
framePayload = parseStreamFramePayload(data);
|
|
1936
|
+
if (framePayload.type === "start") {
|
|
1937
|
+
this.handleIncomingStreamStartFrame(client, framePayload);
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (framePayload.type === "chunk") {
|
|
1941
|
+
this.handleIncomingStreamChunkFrame(client, framePayload);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
if (framePayload.type === "end") {
|
|
1945
|
+
this.handleIncomingStreamEndFrame(client, framePayload);
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
this.handleIncomingStreamAbortFrame(client, framePayload);
|
|
1949
|
+
} catch (error) {
|
|
1950
|
+
const normalizedError = normalizeToError(
|
|
1951
|
+
error,
|
|
1952
|
+
`Failed to process incoming stream frame for client ${client.id}.`
|
|
1953
|
+
);
|
|
1954
|
+
if (framePayload) {
|
|
1955
|
+
this.abortIncomingServerStream(
|
|
1956
|
+
client.socket,
|
|
1957
|
+
framePayload.streamId,
|
|
1958
|
+
normalizedError.message
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
this.notifyError(normalizedError);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1214
1964
|
async executeServerMiddleware(context) {
|
|
1215
1965
|
if (this.middlewareHandlers.length === 0) {
|
|
1216
1966
|
return;
|
|
@@ -1446,10 +2196,104 @@ var SecureServer = class {
|
|
|
1446
2196
|
this.sendRaw(
|
|
1447
2197
|
socket,
|
|
1448
2198
|
serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
|
|
2199
|
+
type: "hello",
|
|
2200
|
+
protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
|
|
1449
2201
|
publicKey: localPublicKey
|
|
1450
2202
|
})
|
|
1451
2203
|
);
|
|
1452
2204
|
}
|
|
2205
|
+
sendResumeAck(socket, payload) {
|
|
2206
|
+
const responsePayload = {
|
|
2207
|
+
type: "resume-ack",
|
|
2208
|
+
protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
|
|
2209
|
+
ok: payload.ok
|
|
2210
|
+
};
|
|
2211
|
+
if (payload.sessionId !== void 0 && payload.sessionId.length > 0) {
|
|
2212
|
+
responsePayload.sessionId = payload.sessionId;
|
|
2213
|
+
}
|
|
2214
|
+
if (payload.serverProof !== void 0 && payload.serverProof.length > 0) {
|
|
2215
|
+
responsePayload.serverProof = payload.serverProof;
|
|
2216
|
+
}
|
|
2217
|
+
if (payload.reason !== void 0 && payload.reason.length > 0) {
|
|
2218
|
+
responsePayload.reason = payload.reason;
|
|
2219
|
+
}
|
|
2220
|
+
this.sendRaw(
|
|
2221
|
+
socket,
|
|
2222
|
+
serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, responsePayload)
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
handleResumeHandshake(client, payload) {
|
|
2226
|
+
if (!this.sessionResumptionConfig.enabled) {
|
|
2227
|
+
this.sendResumeAck(client.socket, {
|
|
2228
|
+
ok: false,
|
|
2229
|
+
reason: "Session resumption is disabled."
|
|
2230
|
+
});
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
const ticketRecord = this.getSessionTicket(payload.sessionId);
|
|
2234
|
+
if (!ticketRecord) {
|
|
2235
|
+
this.sendResumeAck(client.socket, {
|
|
2236
|
+
ok: false,
|
|
2237
|
+
reason: "Session ticket is unknown or expired."
|
|
2238
|
+
});
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
try {
|
|
2242
|
+
const clientNonce = decodeBase64ToBuffer(
|
|
2243
|
+
payload.clientNonce,
|
|
2244
|
+
"Handshake resume clientNonce"
|
|
2245
|
+
);
|
|
2246
|
+
if (clientNonce.length !== RESUMPTION_NONCE_LENGTH) {
|
|
2247
|
+
throw new Error(
|
|
2248
|
+
`Handshake resume clientNonce must be ${RESUMPTION_NONCE_LENGTH} bytes.`
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
const receivedProof = decodeBase64ToBuffer(
|
|
2252
|
+
payload.clientProof,
|
|
2253
|
+
"Handshake resume clientProof"
|
|
2254
|
+
);
|
|
2255
|
+
const expectedProof = createResumeClientProof(
|
|
2256
|
+
ticketRecord.secret,
|
|
2257
|
+
ticketRecord.sessionId,
|
|
2258
|
+
clientNonce
|
|
2259
|
+
);
|
|
2260
|
+
if (!equalsConstantTime(receivedProof, expectedProof)) {
|
|
2261
|
+
this.sendResumeAck(client.socket, {
|
|
2262
|
+
ok: false,
|
|
2263
|
+
reason: "Session resumption proof validation failed."
|
|
2264
|
+
});
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
this.sessionTicketStore.delete(ticketRecord.sessionId);
|
|
2268
|
+
const resumedKey = deriveResumedEncryptionKey(ticketRecord.secret, clientNonce);
|
|
2269
|
+
const serverProof = createResumeServerProof(
|
|
2270
|
+
resumedKey,
|
|
2271
|
+
ticketRecord.sessionId,
|
|
2272
|
+
clientNonce
|
|
2273
|
+
).toString("base64");
|
|
2274
|
+
const handshakeState = this.handshakeStateBySocket.get(client.socket);
|
|
2275
|
+
if (!handshakeState) {
|
|
2276
|
+
throw new Error(`Missing handshake state for client ${client.id}.`);
|
|
2277
|
+
}
|
|
2278
|
+
this.sharedSecretBySocket.set(client.socket, resumedKey);
|
|
2279
|
+
this.encryptionKeyBySocket.set(client.socket, resumedKey);
|
|
2280
|
+
handshakeState.isReady = true;
|
|
2281
|
+
this.sendResumeAck(client.socket, {
|
|
2282
|
+
ok: true,
|
|
2283
|
+
sessionId: ticketRecord.sessionId,
|
|
2284
|
+
serverProof
|
|
2285
|
+
});
|
|
2286
|
+
void this.flushQueuedPayloads(client.socket);
|
|
2287
|
+
this.notifyReady(client);
|
|
2288
|
+
this.issueSessionTicket(client.socket, resumedKey);
|
|
2289
|
+
} catch (error) {
|
|
2290
|
+
this.sendResumeAck(client.socket, {
|
|
2291
|
+
ok: false,
|
|
2292
|
+
reason: "Session resumption payload was invalid."
|
|
2293
|
+
});
|
|
2294
|
+
this.notifyError(normalizeToError(error, "Failed to resume secure server session."));
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
1453
2297
|
handleInternalHandshake(client, data) {
|
|
1454
2298
|
try {
|
|
1455
2299
|
const payload = parseHandshakePayload(data);
|
|
@@ -1460,14 +2304,22 @@ var SecureServer = class {
|
|
|
1460
2304
|
if (handshakeState.isReady) {
|
|
1461
2305
|
return;
|
|
1462
2306
|
}
|
|
2307
|
+
if (payload.type === "resume") {
|
|
2308
|
+
this.handleResumeHandshake(client, payload);
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
if (payload.type === "resume-ack") {
|
|
2312
|
+
throw new Error("SecureServer received unexpected resume-ack handshake payload.");
|
|
2313
|
+
}
|
|
1463
2314
|
const remotePublicKey = Buffer.from(payload.publicKey, "base64");
|
|
1464
2315
|
const sharedSecret = handshakeState.ecdh.computeSecret(remotePublicKey);
|
|
1465
2316
|
const encryptionKey = deriveEncryptionKey(sharedSecret);
|
|
1466
2317
|
this.sharedSecretBySocket.set(client.socket, sharedSecret);
|
|
1467
2318
|
this.encryptionKeyBySocket.set(client.socket, encryptionKey);
|
|
1468
2319
|
handshakeState.isReady = true;
|
|
1469
|
-
this.flushQueuedPayloads(client.socket);
|
|
2320
|
+
void this.flushQueuedPayloads(client.socket);
|
|
1470
2321
|
this.notifyReady(client);
|
|
2322
|
+
this.issueSessionTicket(client.socket, encryptionKey);
|
|
1471
2323
|
} catch (error) {
|
|
1472
2324
|
this.notifyError(normalizeToError(error, "Failed to complete server handshake."));
|
|
1473
2325
|
}
|
|
@@ -1537,6 +2389,9 @@ var SecureServer = class {
|
|
|
1537
2389
|
}
|
|
1538
2390
|
return this.emitTo(clientId, event, data, callbackOrOptions ?? {});
|
|
1539
2391
|
},
|
|
2392
|
+
emitStream: (event, source, options) => {
|
|
2393
|
+
return this.emitStreamTo(clientId, event, source, options);
|
|
2394
|
+
},
|
|
1540
2395
|
join: (room) => this.joinClientToRoom(clientId, room),
|
|
1541
2396
|
leave: (room) => this.leaveClientFromRoom(clientId, room),
|
|
1542
2397
|
leaveAll: () => this.leaveClientFromAllRooms(clientId)
|
|
@@ -1681,6 +2536,9 @@ var SecureClient = class {
|
|
|
1681
2536
|
this.url = url;
|
|
1682
2537
|
this.options = options;
|
|
1683
2538
|
this.reconnectConfig = this.resolveReconnectConfig(this.options.reconnect);
|
|
2539
|
+
this.sessionResumptionConfig = this.resolveSessionResumptionConfig(
|
|
2540
|
+
this.options.sessionResumption
|
|
2541
|
+
);
|
|
1684
2542
|
if (this.options.autoConnect ?? true) {
|
|
1685
2543
|
this.connect();
|
|
1686
2544
|
}
|
|
@@ -1689,10 +2547,12 @@ var SecureClient = class {
|
|
|
1689
2547
|
options;
|
|
1690
2548
|
socket = null;
|
|
1691
2549
|
reconnectConfig;
|
|
2550
|
+
sessionResumptionConfig;
|
|
1692
2551
|
reconnectAttemptCount = 0;
|
|
1693
2552
|
reconnectTimer = null;
|
|
1694
2553
|
isManualDisconnectRequested = false;
|
|
1695
2554
|
customEventHandlers = /* @__PURE__ */ new Map();
|
|
2555
|
+
streamEventHandlers = /* @__PURE__ */ new Map();
|
|
1696
2556
|
connectHandlers = /* @__PURE__ */ new Set();
|
|
1697
2557
|
disconnectHandlers = /* @__PURE__ */ new Set();
|
|
1698
2558
|
readyHandlers = /* @__PURE__ */ new Set();
|
|
@@ -1700,6 +2560,8 @@ var SecureClient = class {
|
|
|
1700
2560
|
handshakeState = null;
|
|
1701
2561
|
pendingPayloadQueue = [];
|
|
1702
2562
|
pendingRpcRequests = /* @__PURE__ */ new Map();
|
|
2563
|
+
incomingStreams = /* @__PURE__ */ new Map();
|
|
2564
|
+
sessionTicket = null;
|
|
1703
2565
|
get readyState() {
|
|
1704
2566
|
return this.socket?.readyState ?? null;
|
|
1705
2567
|
}
|
|
@@ -1758,8 +2620,8 @@ var SecureClient = class {
|
|
|
1758
2620
|
this.errorHandlers.add(handler);
|
|
1759
2621
|
return this;
|
|
1760
2622
|
}
|
|
1761
|
-
if (event === INTERNAL_HANDSHAKE_EVENT) {
|
|
1762
|
-
throw new Error(`The event "${
|
|
2623
|
+
if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
|
|
2624
|
+
throw new Error(`The event "${event}" is reserved for internal use.`);
|
|
1763
2625
|
}
|
|
1764
2626
|
const typedHandler = handler;
|
|
1765
2627
|
const listeners = this.customEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
@@ -1790,7 +2652,7 @@ var SecureClient = class {
|
|
|
1790
2652
|
this.errorHandlers.delete(handler);
|
|
1791
2653
|
return this;
|
|
1792
2654
|
}
|
|
1793
|
-
if (event === INTERNAL_HANDSHAKE_EVENT) {
|
|
2655
|
+
if (event === INTERNAL_HANDSHAKE_EVENT || event === INTERNAL_SESSION_TICKET_EVENT) {
|
|
1794
2656
|
return this;
|
|
1795
2657
|
}
|
|
1796
2658
|
const listeners = this.customEventHandlers.get(event);
|
|
@@ -1808,6 +2670,38 @@ var SecureClient = class {
|
|
|
1808
2670
|
}
|
|
1809
2671
|
return this;
|
|
1810
2672
|
}
|
|
2673
|
+
onStream(event, handler) {
|
|
2674
|
+
try {
|
|
2675
|
+
if (isReservedEmitEvent(event)) {
|
|
2676
|
+
throw new Error(`The event "${event}" is reserved and cannot be used as a stream event.`);
|
|
2677
|
+
}
|
|
2678
|
+
const listeners = this.streamEventHandlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
2679
|
+
listeners.add(handler);
|
|
2680
|
+
this.streamEventHandlers.set(event, listeners);
|
|
2681
|
+
} catch (error) {
|
|
2682
|
+
this.notifyError(
|
|
2683
|
+
normalizeToError(error, "Failed to register client stream handler.")
|
|
2684
|
+
);
|
|
2685
|
+
}
|
|
2686
|
+
return this;
|
|
2687
|
+
}
|
|
2688
|
+
offStream(event, handler) {
|
|
2689
|
+
try {
|
|
2690
|
+
const listeners = this.streamEventHandlers.get(event);
|
|
2691
|
+
if (!listeners) {
|
|
2692
|
+
return this;
|
|
2693
|
+
}
|
|
2694
|
+
listeners.delete(handler);
|
|
2695
|
+
if (listeners.size === 0) {
|
|
2696
|
+
this.streamEventHandlers.delete(event);
|
|
2697
|
+
}
|
|
2698
|
+
} catch (error) {
|
|
2699
|
+
this.notifyError(
|
|
2700
|
+
normalizeToError(error, "Failed to remove client stream handler.")
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
return this;
|
|
2704
|
+
}
|
|
1811
2705
|
emit(event, data, callbackOrOptions, maybeCallback) {
|
|
1812
2706
|
const ackArgs = resolveAckArguments(callbackOrOptions, maybeCallback);
|
|
1813
2707
|
try {
|
|
@@ -1853,6 +2747,39 @@ var SecureClient = class {
|
|
|
1853
2747
|
return false;
|
|
1854
2748
|
}
|
|
1855
2749
|
}
|
|
2750
|
+
async emitStream(event, source, options) {
|
|
2751
|
+
try {
|
|
2752
|
+
if (isReservedEmitEvent(event)) {
|
|
2753
|
+
throw new Error(`The event "${event}" is reserved and cannot be emitted manually.`);
|
|
2754
|
+
}
|
|
2755
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
2756
|
+
throw new Error("Client socket is not connected.");
|
|
2757
|
+
}
|
|
2758
|
+
if (!this.isHandshakeReady()) {
|
|
2759
|
+
throw new Error(
|
|
2760
|
+
`Cannot stream event "${event}" before secure handshake completion.`
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
return await transmitChunkedStreamFrames(
|
|
2764
|
+
event,
|
|
2765
|
+
source,
|
|
2766
|
+
options,
|
|
2767
|
+
async (framePayload) => {
|
|
2768
|
+
await this.sendEncryptedEnvelope({
|
|
2769
|
+
event: INTERNAL_STREAM_FRAME_EVENT,
|
|
2770
|
+
data: framePayload
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
);
|
|
2774
|
+
} catch (error) {
|
|
2775
|
+
const normalizedError = normalizeToError(
|
|
2776
|
+
error,
|
|
2777
|
+
`Failed to emit chunked stream event "${event}".`
|
|
2778
|
+
);
|
|
2779
|
+
this.notifyError(normalizedError);
|
|
2780
|
+
throw normalizedError;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
1856
2783
|
resolveReconnectConfig(reconnectOptions) {
|
|
1857
2784
|
if (typeof reconnectOptions === "boolean") {
|
|
1858
2785
|
return {
|
|
@@ -1896,6 +2823,24 @@ var SecureClient = class {
|
|
|
1896
2823
|
maxAttempts
|
|
1897
2824
|
};
|
|
1898
2825
|
}
|
|
2826
|
+
resolveSessionResumptionConfig(sessionResumptionOptions) {
|
|
2827
|
+
if (typeof sessionResumptionOptions === "boolean") {
|
|
2828
|
+
return {
|
|
2829
|
+
enabled: sessionResumptionOptions,
|
|
2830
|
+
maxAcceptedTicketTtlMs: DEFAULT_SESSION_TICKET_TTL_MS
|
|
2831
|
+
};
|
|
2832
|
+
}
|
|
2833
|
+
const maxAcceptedTicketTtlMs = sessionResumptionOptions?.maxAcceptedTicketTtlMs ?? DEFAULT_SESSION_TICKET_TTL_MS;
|
|
2834
|
+
if (!Number.isFinite(maxAcceptedTicketTtlMs) || maxAcceptedTicketTtlMs <= 0) {
|
|
2835
|
+
throw new Error(
|
|
2836
|
+
"Client sessionResumption maxAcceptedTicketTtlMs must be a positive number."
|
|
2837
|
+
);
|
|
2838
|
+
}
|
|
2839
|
+
return {
|
|
2840
|
+
enabled: sessionResumptionOptions?.enabled ?? DEFAULT_SESSION_RESUMPTION_ENABLED,
|
|
2841
|
+
maxAcceptedTicketTtlMs
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
1899
2844
|
scheduleReconnect() {
|
|
1900
2845
|
if (!this.reconnectConfig.enabled || this.reconnectTimer) {
|
|
1901
2846
|
return;
|
|
@@ -1943,7 +2888,6 @@ var SecureClient = class {
|
|
|
1943
2888
|
socket.on("open", () => {
|
|
1944
2889
|
this.clearReconnectTimer();
|
|
1945
2890
|
this.reconnectAttemptCount = 0;
|
|
1946
|
-
this.sendInternalHandshake();
|
|
1947
2891
|
this.notifyConnect();
|
|
1948
2892
|
});
|
|
1949
2893
|
socket.on("message", (rawData) => {
|
|
@@ -1999,6 +2943,14 @@ var SecureClient = class {
|
|
|
1999
2943
|
void this.handleRpcRequest(decryptedEnvelope.data);
|
|
2000
2944
|
return;
|
|
2001
2945
|
}
|
|
2946
|
+
if (decryptedEnvelope.event === INTERNAL_STREAM_FRAME_EVENT) {
|
|
2947
|
+
this.handleIncomingStreamFrame(decryptedEnvelope.data);
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
if (decryptedEnvelope.event === INTERNAL_SESSION_TICKET_EVENT) {
|
|
2951
|
+
this.handleSessionTicket(decryptedEnvelope.data);
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2002
2954
|
this.dispatchCustomEvent(decryptedEnvelope.event, decryptedEnvelope.data);
|
|
2003
2955
|
} catch (error) {
|
|
2004
2956
|
this.notifyError(normalizeToError(error, "Failed to process incoming client message."));
|
|
@@ -2009,6 +2961,9 @@ var SecureClient = class {
|
|
|
2009
2961
|
this.socket = null;
|
|
2010
2962
|
this.handshakeState = null;
|
|
2011
2963
|
this.pendingPayloadQueue = [];
|
|
2964
|
+
this.cleanupIncomingStreams(
|
|
2965
|
+
"Client disconnected before stream transfer completed."
|
|
2966
|
+
);
|
|
2012
2967
|
this.rejectPendingRpcRequests(
|
|
2013
2968
|
new Error("Client disconnected before ACK response was received.")
|
|
2014
2969
|
);
|
|
@@ -2055,6 +3010,151 @@ var SecureClient = class {
|
|
|
2055
3010
|
}
|
|
2056
3011
|
}
|
|
2057
3012
|
}
|
|
3013
|
+
cleanupIncomingStreams(reason) {
|
|
3014
|
+
for (const streamState of this.incomingStreams.values()) {
|
|
3015
|
+
streamState.stream.destroy(new Error(reason));
|
|
3016
|
+
}
|
|
3017
|
+
this.incomingStreams.clear();
|
|
3018
|
+
}
|
|
3019
|
+
abortIncomingClientStream(streamId, reason) {
|
|
3020
|
+
const streamState = this.incomingStreams.get(streamId);
|
|
3021
|
+
if (!streamState) {
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
streamState.stream.destroy(new Error(reason));
|
|
3025
|
+
this.incomingStreams.delete(streamId);
|
|
3026
|
+
}
|
|
3027
|
+
dispatchClientStreamEvent(event, stream, info) {
|
|
3028
|
+
const handlers = this.streamEventHandlers.get(event);
|
|
3029
|
+
if (!handlers || handlers.size === 0) {
|
|
3030
|
+
stream.resume();
|
|
3031
|
+
this.notifyError(
|
|
3032
|
+
new Error(`No stream handler is registered for event "${event}" on client.`)
|
|
3033
|
+
);
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
for (const handler of handlers) {
|
|
3037
|
+
try {
|
|
3038
|
+
const handlerResult = handler(stream, info);
|
|
3039
|
+
if (isPromiseLike(handlerResult)) {
|
|
3040
|
+
void Promise.resolve(handlerResult).catch((error) => {
|
|
3041
|
+
this.notifyError(
|
|
3042
|
+
normalizeToError(
|
|
3043
|
+
error,
|
|
3044
|
+
`Client stream handler failed for event ${event}.`
|
|
3045
|
+
)
|
|
3046
|
+
);
|
|
3047
|
+
});
|
|
3048
|
+
}
|
|
3049
|
+
} catch (error) {
|
|
3050
|
+
this.notifyError(
|
|
3051
|
+
normalizeToError(error, `Client stream handler failed for event ${event}.`)
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
handleIncomingClientStreamStartFrame(framePayload) {
|
|
3057
|
+
if (isReservedEmitEvent(framePayload.event)) {
|
|
3058
|
+
throw new Error(
|
|
3059
|
+
`Reserved event "${framePayload.event}" cannot be used for stream transport.`
|
|
3060
|
+
);
|
|
3061
|
+
}
|
|
3062
|
+
if (this.incomingStreams.has(framePayload.streamId)) {
|
|
3063
|
+
throw new Error(`Stream ${framePayload.streamId} already exists on client.`);
|
|
3064
|
+
}
|
|
3065
|
+
const stream = new PassThrough();
|
|
3066
|
+
const streamInfo = {
|
|
3067
|
+
streamId: framePayload.streamId,
|
|
3068
|
+
event: framePayload.event,
|
|
3069
|
+
startedAt: Date.now(),
|
|
3070
|
+
...framePayload.metadata !== void 0 ? { metadata: framePayload.metadata } : {},
|
|
3071
|
+
...framePayload.totalBytes !== void 0 ? { totalBytes: framePayload.totalBytes } : {}
|
|
3072
|
+
};
|
|
3073
|
+
this.incomingStreams.set(framePayload.streamId, {
|
|
3074
|
+
info: streamInfo,
|
|
3075
|
+
stream,
|
|
3076
|
+
expectedChunkIndex: 0,
|
|
3077
|
+
receivedBytes: 0
|
|
3078
|
+
});
|
|
3079
|
+
this.dispatchClientStreamEvent(framePayload.event, stream, streamInfo);
|
|
3080
|
+
}
|
|
3081
|
+
handleIncomingClientStreamChunkFrame(framePayload) {
|
|
3082
|
+
const streamState = this.incomingStreams.get(framePayload.streamId);
|
|
3083
|
+
if (!streamState) {
|
|
3084
|
+
throw new Error(`Stream ${framePayload.streamId} is unknown on client.`);
|
|
3085
|
+
}
|
|
3086
|
+
if (framePayload.index !== streamState.expectedChunkIndex) {
|
|
3087
|
+
throw new Error(
|
|
3088
|
+
`Out-of-order chunk index for stream ${framePayload.streamId}. Expected ${streamState.expectedChunkIndex}, received ${framePayload.index}.`
|
|
3089
|
+
);
|
|
3090
|
+
}
|
|
3091
|
+
const chunkBuffer = decodeBase64ToBuffer(
|
|
3092
|
+
framePayload.payload,
|
|
3093
|
+
`Stream chunk payload (${framePayload.streamId})`
|
|
3094
|
+
);
|
|
3095
|
+
if (chunkBuffer.length !== framePayload.byteLength) {
|
|
3096
|
+
throw new Error(
|
|
3097
|
+
`Stream ${framePayload.streamId} byteLength mismatch. Expected ${framePayload.byteLength}, received ${chunkBuffer.length}.`
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
streamState.expectedChunkIndex += 1;
|
|
3101
|
+
streamState.receivedBytes += chunkBuffer.length;
|
|
3102
|
+
streamState.stream.write(chunkBuffer);
|
|
3103
|
+
}
|
|
3104
|
+
handleIncomingClientStreamEndFrame(framePayload) {
|
|
3105
|
+
const streamState = this.incomingStreams.get(framePayload.streamId);
|
|
3106
|
+
if (!streamState) {
|
|
3107
|
+
throw new Error(`Stream ${framePayload.streamId} is unknown on client.`);
|
|
3108
|
+
}
|
|
3109
|
+
if (framePayload.chunkCount !== streamState.expectedChunkIndex) {
|
|
3110
|
+
throw new Error(
|
|
3111
|
+
`Stream ${framePayload.streamId} chunkCount mismatch. Expected ${streamState.expectedChunkIndex}, received ${framePayload.chunkCount}.`
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
if (framePayload.totalBytes !== streamState.receivedBytes) {
|
|
3115
|
+
throw new Error(
|
|
3116
|
+
`Stream ${framePayload.streamId} totalBytes mismatch. Expected ${streamState.receivedBytes}, received ${framePayload.totalBytes}.`
|
|
3117
|
+
);
|
|
3118
|
+
}
|
|
3119
|
+
if (streamState.info.totalBytes !== void 0 && streamState.info.totalBytes !== streamState.receivedBytes) {
|
|
3120
|
+
throw new Error(
|
|
3121
|
+
`Stream ${framePayload.streamId} violated announced totalBytes (${streamState.info.totalBytes}).`
|
|
3122
|
+
);
|
|
3123
|
+
}
|
|
3124
|
+
streamState.stream.end();
|
|
3125
|
+
this.incomingStreams.delete(framePayload.streamId);
|
|
3126
|
+
}
|
|
3127
|
+
handleIncomingClientStreamAbortFrame(framePayload) {
|
|
3128
|
+
this.abortIncomingClientStream(framePayload.streamId, framePayload.reason);
|
|
3129
|
+
}
|
|
3130
|
+
handleIncomingStreamFrame(data) {
|
|
3131
|
+
let framePayload = null;
|
|
3132
|
+
try {
|
|
3133
|
+
framePayload = parseStreamFramePayload(data);
|
|
3134
|
+
if (framePayload.type === "start") {
|
|
3135
|
+
this.handleIncomingClientStreamStartFrame(framePayload);
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
if (framePayload.type === "chunk") {
|
|
3139
|
+
this.handleIncomingClientStreamChunkFrame(framePayload);
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
if (framePayload.type === "end") {
|
|
3143
|
+
this.handleIncomingClientStreamEndFrame(framePayload);
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
3146
|
+
this.handleIncomingClientStreamAbortFrame(framePayload);
|
|
3147
|
+
} catch (error) {
|
|
3148
|
+
const normalizedError = normalizeToError(
|
|
3149
|
+
error,
|
|
3150
|
+
"Failed to process incoming stream frame on client."
|
|
3151
|
+
);
|
|
3152
|
+
if (framePayload) {
|
|
3153
|
+
this.abortIncomingClientStream(framePayload.streamId, normalizedError.message);
|
|
3154
|
+
}
|
|
3155
|
+
this.notifyError(normalizedError);
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
2058
3158
|
notifyConnect() {
|
|
2059
3159
|
for (const handler of this.connectHandlers) {
|
|
2060
3160
|
try {
|
|
@@ -2212,11 +3312,45 @@ var SecureClient = class {
|
|
|
2212
3312
|
}
|
|
2213
3313
|
this.pendingRpcRequests.clear();
|
|
2214
3314
|
}
|
|
3315
|
+
handleSessionTicket(data) {
|
|
3316
|
+
if (!this.sessionResumptionConfig.enabled) {
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
try {
|
|
3320
|
+
const ticketPayload = parseSessionTicketPayload(data);
|
|
3321
|
+
const now = Date.now();
|
|
3322
|
+
if (ticketPayload.expiresAt <= now) {
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3325
|
+
const ticketTtlMs = ticketPayload.expiresAt - ticketPayload.issuedAt;
|
|
3326
|
+
if (ticketTtlMs > this.sessionResumptionConfig.maxAcceptedTicketTtlMs) {
|
|
3327
|
+
throw new Error("Session ticket TTL exceeds client trust policy.");
|
|
3328
|
+
}
|
|
3329
|
+
const sessionSecret = decodeBase64ToBuffer(
|
|
3330
|
+
ticketPayload.secret,
|
|
3331
|
+
"Session ticket secret"
|
|
3332
|
+
);
|
|
3333
|
+
if (sessionSecret.length !== ENCRYPTION_KEY_LENGTH) {
|
|
3334
|
+
throw new Error("Session ticket secret has invalid length.");
|
|
3335
|
+
}
|
|
3336
|
+
this.sessionTicket = {
|
|
3337
|
+
sessionId: ticketPayload.sessionId,
|
|
3338
|
+
secret: sessionSecret,
|
|
3339
|
+
issuedAt: ticketPayload.issuedAt,
|
|
3340
|
+
expiresAt: ticketPayload.expiresAt
|
|
3341
|
+
};
|
|
3342
|
+
} catch (error) {
|
|
3343
|
+
this.notifyError(normalizeToError(error, "Failed to process session ticket payload."));
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
2215
3346
|
createClientHandshakeState() {
|
|
2216
3347
|
const { ecdh, localPublicKey } = createEphemeralHandshakeState();
|
|
2217
3348
|
return {
|
|
2218
3349
|
ecdh,
|
|
2219
3350
|
localPublicKey,
|
|
3351
|
+
clientHelloSent: false,
|
|
3352
|
+
pendingServerPublicKey: null,
|
|
3353
|
+
resumeAttempt: null,
|
|
2220
3354
|
isReady: false,
|
|
2221
3355
|
sharedSecret: null,
|
|
2222
3356
|
encryptionKey: null
|
|
@@ -2230,15 +3364,171 @@ var SecureClient = class {
|
|
|
2230
3364
|
if (!this.handshakeState) {
|
|
2231
3365
|
throw new Error("Missing client handshake state.");
|
|
2232
3366
|
}
|
|
3367
|
+
if (this.handshakeState.clientHelloSent) {
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
2233
3370
|
this.socket.send(
|
|
2234
3371
|
serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
|
|
3372
|
+
type: "hello",
|
|
3373
|
+
protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
|
|
2235
3374
|
publicKey: this.handshakeState.localPublicKey
|
|
2236
3375
|
})
|
|
2237
3376
|
);
|
|
3377
|
+
this.handshakeState.clientHelloSent = true;
|
|
2238
3378
|
} catch (error) {
|
|
2239
3379
|
this.notifyError(normalizeToError(error, "Failed to send client handshake payload."));
|
|
2240
3380
|
}
|
|
2241
3381
|
}
|
|
3382
|
+
shouldAttemptSessionResumption() {
|
|
3383
|
+
if (!this.sessionResumptionConfig.enabled) {
|
|
3384
|
+
return false;
|
|
3385
|
+
}
|
|
3386
|
+
const sessionTicket = this.sessionTicket;
|
|
3387
|
+
if (!sessionTicket) {
|
|
3388
|
+
return false;
|
|
3389
|
+
}
|
|
3390
|
+
const now = Date.now();
|
|
3391
|
+
if (sessionTicket.expiresAt <= now) {
|
|
3392
|
+
this.sessionTicket = null;
|
|
3393
|
+
return false;
|
|
3394
|
+
}
|
|
3395
|
+
const ticketTtlMs = sessionTicket.expiresAt - sessionTicket.issuedAt;
|
|
3396
|
+
if (ticketTtlMs > this.sessionResumptionConfig.maxAcceptedTicketTtlMs) {
|
|
3397
|
+
this.sessionTicket = null;
|
|
3398
|
+
return false;
|
|
3399
|
+
}
|
|
3400
|
+
return true;
|
|
3401
|
+
}
|
|
3402
|
+
sendResumeHandshake() {
|
|
3403
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
3404
|
+
return false;
|
|
3405
|
+
}
|
|
3406
|
+
if (!this.handshakeState || !this.sessionTicket) {
|
|
3407
|
+
return false;
|
|
3408
|
+
}
|
|
3409
|
+
if (this.handshakeState.clientHelloSent) {
|
|
3410
|
+
return false;
|
|
3411
|
+
}
|
|
3412
|
+
if (this.handshakeState.resumeAttempt?.status === "pending") {
|
|
3413
|
+
return true;
|
|
3414
|
+
}
|
|
3415
|
+
try {
|
|
3416
|
+
const clientNonce = randomBytes(RESUMPTION_NONCE_LENGTH);
|
|
3417
|
+
const resumedKey = deriveResumedEncryptionKey(this.sessionTicket.secret, clientNonce);
|
|
3418
|
+
const clientProof = createResumeClientProof(
|
|
3419
|
+
this.sessionTicket.secret,
|
|
3420
|
+
this.sessionTicket.sessionId,
|
|
3421
|
+
clientNonce
|
|
3422
|
+
);
|
|
3423
|
+
this.socket.send(
|
|
3424
|
+
serializePlainEnvelope(INTERNAL_HANDSHAKE_EVENT, {
|
|
3425
|
+
type: "resume",
|
|
3426
|
+
protocolVersion: HANDSHAKE_PROTOCOL_VERSION,
|
|
3427
|
+
sessionId: this.sessionTicket.sessionId,
|
|
3428
|
+
clientNonce: clientNonce.toString("base64"),
|
|
3429
|
+
clientProof: clientProof.toString("base64")
|
|
3430
|
+
})
|
|
3431
|
+
);
|
|
3432
|
+
this.handshakeState.resumeAttempt = {
|
|
3433
|
+
status: "pending",
|
|
3434
|
+
sessionId: this.sessionTicket.sessionId,
|
|
3435
|
+
clientNonce,
|
|
3436
|
+
resumedKey
|
|
3437
|
+
};
|
|
3438
|
+
return true;
|
|
3439
|
+
} catch (error) {
|
|
3440
|
+
this.notifyError(normalizeToError(error, "Failed to dispatch resume handshake payload."));
|
|
3441
|
+
this.sessionTicket = null;
|
|
3442
|
+
this.handshakeState.resumeAttempt = null;
|
|
3443
|
+
return false;
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
completeFullHandshake(serverPublicKey) {
|
|
3447
|
+
if (!this.handshakeState) {
|
|
3448
|
+
throw new Error("Missing client handshake state.");
|
|
3449
|
+
}
|
|
3450
|
+
if (this.handshakeState.isReady) {
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3453
|
+
this.sendInternalHandshake();
|
|
3454
|
+
const remotePublicKey = Buffer.from(serverPublicKey, "base64");
|
|
3455
|
+
const sharedSecret = this.handshakeState.ecdh.computeSecret(remotePublicKey);
|
|
3456
|
+
this.handshakeState.sharedSecret = sharedSecret;
|
|
3457
|
+
this.handshakeState.encryptionKey = deriveEncryptionKey(sharedSecret);
|
|
3458
|
+
this.handshakeState.resumeAttempt = null;
|
|
3459
|
+
this.handshakeState.pendingServerPublicKey = null;
|
|
3460
|
+
this.handshakeState.isReady = true;
|
|
3461
|
+
void this.flushPendingPayloadQueue();
|
|
3462
|
+
this.notifyReady();
|
|
3463
|
+
}
|
|
3464
|
+
fallbackToFullHandshake() {
|
|
3465
|
+
if (!this.handshakeState || this.handshakeState.isReady) {
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
if (this.handshakeState.resumeAttempt) {
|
|
3469
|
+
this.handshakeState.resumeAttempt.status = "failed";
|
|
3470
|
+
}
|
|
3471
|
+
const pendingServerPublicKey = this.handshakeState.pendingServerPublicKey;
|
|
3472
|
+
if (pendingServerPublicKey) {
|
|
3473
|
+
this.completeFullHandshake(pendingServerPublicKey);
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
this.sendInternalHandshake();
|
|
3477
|
+
}
|
|
3478
|
+
handleServerHelloHandshake(payload) {
|
|
3479
|
+
if (!this.handshakeState || this.handshakeState.isReady) {
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
this.handshakeState.pendingServerPublicKey = payload.publicKey;
|
|
3483
|
+
if (this.shouldAttemptSessionResumption() && this.sendResumeHandshake()) {
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
this.completeFullHandshake(payload.publicKey);
|
|
3487
|
+
}
|
|
3488
|
+
handleResumeAckHandshake(payload) {
|
|
3489
|
+
if (!this.handshakeState || this.handshakeState.isReady) {
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
const resumeAttempt = this.handshakeState.resumeAttempt;
|
|
3493
|
+
if (!resumeAttempt || resumeAttempt.status !== "pending") {
|
|
3494
|
+
return;
|
|
3495
|
+
}
|
|
3496
|
+
if (!payload.ok) {
|
|
3497
|
+
this.sessionTicket = null;
|
|
3498
|
+
this.fallbackToFullHandshake();
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
if (payload.sessionId !== resumeAttempt.sessionId || !payload.serverProof) {
|
|
3502
|
+
this.sessionTicket = null;
|
|
3503
|
+
this.fallbackToFullHandshake();
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
try {
|
|
3507
|
+
const receivedServerProof = decodeBase64ToBuffer(
|
|
3508
|
+
payload.serverProof,
|
|
3509
|
+
"Handshake resume-ack serverProof"
|
|
3510
|
+
);
|
|
3511
|
+
const expectedServerProof = createResumeServerProof(
|
|
3512
|
+
resumeAttempt.resumedKey,
|
|
3513
|
+
resumeAttempt.sessionId,
|
|
3514
|
+
resumeAttempt.clientNonce
|
|
3515
|
+
);
|
|
3516
|
+
if (!equalsConstantTime(receivedServerProof, expectedServerProof)) {
|
|
3517
|
+
throw new Error("Resume server proof validation failed.");
|
|
3518
|
+
}
|
|
3519
|
+
this.handshakeState.sharedSecret = resumeAttempt.resumedKey;
|
|
3520
|
+
this.handshakeState.encryptionKey = resumeAttempt.resumedKey;
|
|
3521
|
+
this.handshakeState.pendingServerPublicKey = null;
|
|
3522
|
+
resumeAttempt.status = "accepted";
|
|
3523
|
+
this.handshakeState.isReady = true;
|
|
3524
|
+
void this.flushPendingPayloadQueue();
|
|
3525
|
+
this.notifyReady();
|
|
3526
|
+
} catch (error) {
|
|
3527
|
+
this.notifyError(normalizeToError(error, "Failed to verify resume server proof."));
|
|
3528
|
+
this.sessionTicket = null;
|
|
3529
|
+
this.fallbackToFullHandshake();
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
2242
3532
|
handleInternalHandshake(data) {
|
|
2243
3533
|
try {
|
|
2244
3534
|
const payload = parseHandshakePayload(data);
|
|
@@ -2248,13 +3538,15 @@ var SecureClient = class {
|
|
|
2248
3538
|
if (this.handshakeState.isReady) {
|
|
2249
3539
|
return;
|
|
2250
3540
|
}
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
3541
|
+
if (payload.type === "hello") {
|
|
3542
|
+
this.handleServerHelloHandshake(payload);
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
if (payload.type === "resume-ack") {
|
|
3546
|
+
this.handleResumeAckHandshake(payload);
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
throw new Error("SecureClient received unexpected resume request handshake payload.");
|
|
2258
3550
|
} catch (error) {
|
|
2259
3551
|
this.notifyError(normalizeToError(error, "Failed to complete client handshake."));
|
|
2260
3552
|
}
|