@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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { randomUUID, createDecipheriv, randomBytes, createCipheriv, createECDH, createHash } from 'crypto';
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.publicKey !== "string" || payload.publicKey.length === 0) {
342
- throw new Error("Handshake payload must include a non-empty public key.");
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
- publicKey: payload.publicKey
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 "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
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 "${INTERNAL_HANDSHAKE_EVENT}" is reserved for internal use.`);
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
- const remotePublicKey = Buffer.from(payload.publicKey, "base64");
2252
- const sharedSecret = this.handshakeState.ecdh.computeSecret(remotePublicKey);
2253
- this.handshakeState.sharedSecret = sharedSecret;
2254
- this.handshakeState.encryptionKey = deriveEncryptionKey(sharedSecret);
2255
- this.handshakeState.isReady = true;
2256
- void this.flushPendingPayloadQueue();
2257
- this.notifyReady();
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
  }