@vex-chat/libvex 6.5.1 → 6.5.3

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/src/Client.ts CHANGED
@@ -350,18 +350,6 @@ import { uuidToUint8 } from "./utils/uint8uuid.js";
350
350
 
351
351
  const _protocolMsgRegex = /��\w+:\w+��/g;
352
352
 
353
- /**
354
- * Permission is a permission to a resource.
355
- *
356
- * Common fields:
357
- * - `permissionID`: unique permission row ID
358
- * - `userID`: user receiving this grant
359
- * - `resourceID`: target server/channel/etc.
360
- * - `resourceType`: type string for the resource
361
- * - `powerLevel`: authorization level
362
- */
363
- export type { Permission } from "@vex-chat/types";
364
-
365
353
  /**
366
354
  * @ignore
367
355
  */
@@ -378,27 +366,6 @@ export interface Channels {
378
366
  userList: (channelID: string) => Promise<User[]>;
379
367
  }
380
368
 
381
- /**
382
- * Device record associated with a user account.
383
- *
384
- * Common fields:
385
- * - `deviceID`: unique device identifier
386
- * - `owner`: owning user ID
387
- * - `signKey`: signing public key
388
- * - `name`: user-facing device name
389
- * - `lastLogin`: last login timestamp string
390
- * - `deleted`: soft-delete flag
391
- */
392
- export type { Device } from "@vex-chat/types";
393
-
394
- /**
395
- * Public passkey record returned by `client.passkeys.list()` and
396
- * `client.passkeys.finishRegistration()`. Server-private fields
397
- * (credential ID, public key, COSE algorithm, signature counter) are
398
- * never exposed.
399
- */
400
- export type { Passkey } from "@vex-chat/types";
401
-
402
369
  /**
403
370
  * ClientOptions are the options you can pass into the client.
404
371
  */
@@ -436,8 +403,41 @@ export interface ClientOptions {
436
403
  unsafeHttp?: boolean;
437
404
  }
438
405
 
406
+ /**
407
+ * Permission is a permission to a resource.
408
+ *
409
+ * Common fields:
410
+ * - `permissionID`: unique permission row ID
411
+ * - `userID`: user receiving this grant
412
+ * - `resourceID`: target server/channel/etc.
413
+ * - `resourceType`: type string for the resource
414
+ * - `powerLevel`: authorization level
415
+ */
416
+ export type { Permission } from "@vex-chat/types";
417
+
439
418
  export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
440
419
 
420
+ /**
421
+ * Device record associated with a user account.
422
+ *
423
+ * Common fields:
424
+ * - `deviceID`: unique device identifier
425
+ * - `owner`: owning user ID
426
+ * - `signKey`: signing public key
427
+ * - `name`: user-facing device name
428
+ * - `lastLogin`: last login timestamp string
429
+ * - `deleted`: soft-delete flag
430
+ */
431
+ export type { Device } from "@vex-chat/types";
432
+
433
+ /**
434
+ * Public passkey record returned by `client.passkeys.list()` and
435
+ * `client.passkeys.finishRegistration()`. Server-private fields
436
+ * (credential ID, public key, COSE algorithm, signature counter) are
437
+ * never exposed.
438
+ */
439
+ export type { Passkey } from "@vex-chat/types";
440
+
441
441
  /**
442
442
  * @ignore
443
443
  */
@@ -556,6 +556,29 @@ export interface Files {
556
556
  retrieve: (fileID: string, key: string) => Promise<FileResponse | null>;
557
557
  }
558
558
 
559
+ /**
560
+ * @ignore
561
+ */
562
+ export interface Invites {
563
+ /** Creates an invite for a server and duration. */
564
+ create: (serverID: string, duration: string) => Promise<Invite>;
565
+ /** Redeems an invite and returns the created permission grant. */
566
+ redeem: (inviteID: string) => Promise<Permission>;
567
+ /** Lists active invites for a server. */
568
+ retrieve: (serverID: string) => Promise<Invite[]>;
569
+ }
570
+
571
+ /**
572
+ * Keys are a pair of ed25519 public and private keys,
573
+ * encoded as hex strings.
574
+ */
575
+ export interface Keys {
576
+ /** Secret Ed25519 key as hex. Store securely. */
577
+ private: string;
578
+ /** Public Ed25519 key as hex. */
579
+ public: string;
580
+ }
581
+
559
582
  /**
560
583
  * Channel is a chat channel on a server.
561
584
  *
@@ -581,29 +604,6 @@ export type { Server } from "@vex-chat/types";
581
604
  */
582
605
  export type { ServerChannelBootstrap } from "@vex-chat/types";
583
606
 
584
- /**
585
- * @ignore
586
- */
587
- export interface Invites {
588
- /** Creates an invite for a server and duration. */
589
- create: (serverID: string, duration: string) => Promise<Invite>;
590
- /** Redeems an invite and returns the created permission grant. */
591
- redeem: (inviteID: string) => Promise<Permission>;
592
- /** Lists active invites for a server. */
593
- retrieve: (serverID: string) => Promise<Invite[]>;
594
- }
595
-
596
- /**
597
- * Keys are a pair of ed25519 public and private keys,
598
- * encoded as hex strings.
599
- */
600
- export interface Keys {
601
- /** Secret Ed25519 key as hex. Store securely. */
602
- private: string;
603
- /** Public Ed25519 key as hex. */
604
- public: string;
605
- }
606
-
607
607
  /**
608
608
  * @ignore
609
609
  */
@@ -753,6 +753,59 @@ export interface RetryRequest {
753
753
  source: "decrypt_failure" | "server_notify";
754
754
  }
755
755
 
756
+ function compareInboxEntries(
757
+ a: [Uint8Array, MailWS, string],
758
+ b: [Uint8Array, MailWS, string],
759
+ ): number {
760
+ const timeCmp = a[2].localeCompare(b[2]);
761
+ if (timeCmp !== 0) {
762
+ return timeCmp;
763
+ }
764
+
765
+ const aMail = a[1];
766
+ const bMail = b[1];
767
+ if (aMail.sender !== bMail.sender) {
768
+ return aMail.sender.localeCompare(bMail.sender, "en");
769
+ }
770
+
771
+ const typeCmp = aMail.mailType - bMail.mailType;
772
+ if (typeCmp !== 0) {
773
+ return typeCmp;
774
+ }
775
+
776
+ if (
777
+ aMail.mailType === MailType.subsequent &&
778
+ bMail.mailType === MailType.subsequent
779
+ ) {
780
+ const aHeader = tryDecodeRatchetHeader(aMail.extra);
781
+ const bHeader = tryDecodeRatchetHeader(bMail.extra);
782
+ if (aHeader && bHeader) {
783
+ const dhCmp = XUtils.encodeHex(aHeader.dhPub).localeCompare(
784
+ XUtils.encodeHex(bHeader.dhPub),
785
+ "en",
786
+ );
787
+ if (dhCmp !== 0) {
788
+ return dhCmp;
789
+ }
790
+ const pnCmp = aHeader.pn - bHeader.pn;
791
+ if (pnCmp !== 0) {
792
+ return pnCmp;
793
+ }
794
+ return aHeader.n - bHeader.n;
795
+ }
796
+ }
797
+
798
+ return aMail.nonce.toString().localeCompare(bMail.nonce.toString(), "en");
799
+ }
800
+
801
+ function tryDecodeRatchetHeader(extra: Uint8Array) {
802
+ try {
803
+ return decodeRatchetHeader(extra);
804
+ } catch {
805
+ return null;
806
+ }
807
+ }
808
+
756
809
  /** Zod schema matching the {@link Message} interface for forwarded-message decode. */
757
810
  const messageSchema: z.ZodType<Message> = z.object({
758
811
  authorID: z.string(),
@@ -1344,6 +1397,8 @@ export class Client {
1344
1397
  retrieve: this.fetchUser.bind(this),
1345
1398
  };
1346
1399
 
1400
+ private autoReconnectEnabled = false;
1401
+
1347
1402
  private readonly cryptoProfile: CryptoProfile;
1348
1403
 
1349
1404
  private readonly database: Storage;
@@ -1390,18 +1445,19 @@ export class Client {
1390
1445
  private readonly notFoundUsers = new Map<string, number>();
1391
1446
 
1392
1447
  private readonly options?: ClientOptions | undefined;
1393
-
1394
1448
  private pingInterval: null | ReturnType<typeof setTimeout> = null;
1395
1449
  /**
1396
1450
  * Bumped when the WebSocket is torn down and re-opened so the previous
1397
1451
  * `postAuth` loop exits instead of overlapping a new one.
1398
1452
  */
1399
1453
  private postAuthVersion = 0;
1400
-
1401
1454
  private readonly prefixes:
1402
1455
  | { HTTP: "http://"; WS: "ws://" }
1403
1456
  | { HTTP: "https://"; WS: "wss://" };
1404
1457
  private reading: boolean = false;
1458
+ private reconnectAttempt = 0;
1459
+ private reconnectPromise: null | Promise<void> = null;
1460
+ private reconnectTimer: null | ReturnType<typeof setTimeout> = null;
1405
1461
  private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
1406
1462
  private readonly seenMailIDs: Set<string> = new Set();
1407
1463
  private readonly sessionHealBackoffUntil = new Map<string, number>();
@@ -1711,6 +1767,11 @@ export class Client {
1711
1767
  */
1712
1768
  public async close(muteEvent = false): Promise<void> {
1713
1769
  this.manuallyClosing = true;
1770
+ this.autoReconnectEnabled = false;
1771
+ if (this.reconnectTimer) {
1772
+ clearTimeout(this.reconnectTimer);
1773
+ this.reconnectTimer = null;
1774
+ }
1714
1775
  this.httpAbortController.abort();
1715
1776
  this.socket.close();
1716
1777
  await this.database.close();
@@ -1776,6 +1837,7 @@ export class Client {
1776
1837
  const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
1777
1838
  this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
1778
1839
 
1840
+ this.autoReconnectEnabled = true;
1779
1841
  this.initSocket();
1780
1842
  // Yield the event loop so the WS open callback fires and sends the
1781
1843
  // auth message before OTK generation starts. OTK top-up is best-effort
@@ -1979,45 +2041,20 @@ export class Client {
1979
2041
  * newly-registered second device.
1980
2042
  */
1981
2043
  public async reconnectWebsocket(): Promise<void> {
1982
- this.postAuthVersion++;
1983
- if (this.pingInterval) {
1984
- clearInterval(this.pingInterval);
1985
- this.pingInterval = null;
2044
+ if (this.isManualCloseInFlight()) {
2045
+ throw new WebSocketNotOpenError(this.socket.readyState);
1986
2046
  }
1987
- this.socket.close();
1988
- try {
1989
- await new Promise<void>((resolve, reject) => {
1990
- const t = setTimeout(() => {
1991
- this.off("connected", onC);
1992
- reject(
1993
- new Error(
1994
- "reconnectWebsocket: timed out waiting for authorized",
1995
- ),
1996
- );
1997
- }, 15_000);
1998
- const onC = () => {
1999
- clearTimeout(t);
2000
- this.off("connected", onC);
2001
- resolve();
2002
- };
2003
- this.on("connected", onC);
2004
- try {
2005
- this.initSocket();
2006
- } catch (err: unknown) {
2007
- clearTimeout(t);
2008
- this.off("connected", onC);
2009
- const e =
2010
- err instanceof Error
2011
- ? err
2012
- : new Error(String(err), { cause: err });
2013
- reject(e);
2014
- }
2015
- });
2016
- } catch (e: unknown) {
2017
- throw e instanceof Error ? e : new Error(String(e), { cause: e });
2047
+ if (this.reconnectTimer) {
2048
+ clearTimeout(this.reconnectTimer);
2049
+ this.reconnectTimer = null;
2018
2050
  }
2019
- await new Promise((r) => setTimeout(r, 0));
2020
- await this.negotiateOTK();
2051
+ if (this.reconnectPromise) {
2052
+ return this.reconnectPromise;
2053
+ }
2054
+ this.reconnectPromise = this.reconnectWebsocketOnce().finally(() => {
2055
+ this.reconnectPromise = null;
2056
+ });
2057
+ return this.reconnectPromise;
2021
2058
  }
2022
2059
 
2023
2060
  /**
@@ -3017,7 +3054,7 @@ export class Client {
3017
3054
  const rawInbox = z
3018
3055
  .array(mailInboxEntry)
3019
3056
  .parse(msgpack.decode(mailBuffer));
3020
- const inbox = rawInbox.sort((a, b) => b[2].localeCompare(a[2]));
3057
+ const inbox = rawInbox.sort(compareInboxEntries);
3021
3058
 
3022
3059
  if (libvexDebugDmEnabled()) {
3023
3060
  const did = (() => {
@@ -3062,8 +3099,9 @@ export class Client {
3062
3099
  fetchErr,
3063
3100
  );
3064
3101
  }
3102
+ } finally {
3103
+ this.fetchingMail = false;
3065
3104
  }
3066
- this.fetchingMail = false;
3067
3105
  }
3068
3106
 
3069
3107
  private async getMessageHistory(userID: string): Promise<Message[]> {
@@ -3220,7 +3258,6 @@ export class Client {
3220
3258
  }
3221
3259
  case "mail":
3222
3260
  await this.getMail();
3223
- this.fetchingMail = false;
3224
3261
  break;
3225
3262
  case "permission":
3226
3263
  this.emitter.emit(
@@ -3323,12 +3360,14 @@ export class Client {
3323
3360
  }
3324
3361
  if (!this.manuallyClosing) {
3325
3362
  this.emitter.emit("disconnect");
3363
+ this.scheduleReconnect();
3326
3364
  }
3327
3365
  });
3328
3366
 
3329
3367
  this.socket.on("error", (_error: Error) => {
3330
3368
  if (!this.manuallyClosing) {
3331
3369
  this.emitter.emit("disconnect");
3370
+ this.scheduleReconnect();
3332
3371
  }
3333
3372
  });
3334
3373
 
@@ -3363,6 +3402,7 @@ export class Client {
3363
3402
  "Received unauthorized message from server.",
3364
3403
  );
3365
3404
  case "authorized":
3405
+ this.reconnectAttempt = 0;
3366
3406
  this.emitter.emit("connected");
3367
3407
  this.postAuth().catch(ignoreSocketTeardown);
3368
3408
  break;
@@ -3654,7 +3694,6 @@ export class Client {
3654
3694
  try {
3655
3695
  await this.getMail();
3656
3696
  count++;
3657
- this.fetchingMail = false;
3658
3697
 
3659
3698
  if (count > 10) {
3660
3699
  void this.negotiateOTK();
@@ -4234,7 +4273,9 @@ export class Client {
4234
4273
  },
4235
4274
  );
4236
4275
  }
4237
- healSession();
4276
+ if (failureCount >= 2) {
4277
+ healSession();
4278
+ }
4238
4279
  if (failureCount === 1) {
4239
4280
  this.emitter.emit("retryRequest", {
4240
4281
  mailID: mail.mailID,
@@ -4367,6 +4408,48 @@ export class Client {
4367
4408
  }
4368
4409
  }
4369
4410
 
4411
+ private async reconnectWebsocketOnce(): Promise<void> {
4412
+ this.postAuthVersion++;
4413
+ if (this.pingInterval) {
4414
+ clearInterval(this.pingInterval);
4415
+ this.pingInterval = null;
4416
+ }
4417
+ this.socket.close();
4418
+ try {
4419
+ await new Promise<void>((resolve, reject) => {
4420
+ const t = setTimeout(() => {
4421
+ this.off("connected", onC);
4422
+ reject(
4423
+ new Error(
4424
+ "reconnectWebsocket: timed out waiting for authorized",
4425
+ ),
4426
+ );
4427
+ }, 15_000);
4428
+ const onC = () => {
4429
+ clearTimeout(t);
4430
+ this.off("connected", onC);
4431
+ resolve();
4432
+ };
4433
+ this.on("connected", onC);
4434
+ try {
4435
+ this.initSocket();
4436
+ } catch (err: unknown) {
4437
+ clearTimeout(t);
4438
+ this.off("connected", onC);
4439
+ const e =
4440
+ err instanceof Error
4441
+ ? err
4442
+ : new Error(String(err), { cause: err });
4443
+ reject(e);
4444
+ }
4445
+ });
4446
+ } catch (e: unknown) {
4447
+ throw e instanceof Error ? e : new Error(String(e), { cause: e });
4448
+ }
4449
+ await new Promise((r) => setTimeout(r, 0));
4450
+ await this.negotiateOTK();
4451
+ }
4452
+
4370
4453
  private async redeemInvite(inviteID: string): Promise<Permission> {
4371
4454
  const res = await this.http.patch(
4372
4455
  this.getHost() + "/invite/" + inviteID,
@@ -4601,6 +4684,25 @@ export class Client {
4601
4684
  }
4602
4685
  }
4603
4686
 
4687
+ private scheduleReconnect(): void {
4688
+ if (
4689
+ !this.autoReconnectEnabled ||
4690
+ this.isManualCloseInFlight() ||
4691
+ this.reconnectPromise ||
4692
+ this.reconnectTimer
4693
+ ) {
4694
+ return;
4695
+ }
4696
+ const delayMs = Math.min(30_000, 250 * 2 ** this.reconnectAttempt);
4697
+ this.reconnectAttempt = Math.min(this.reconnectAttempt + 1, 8);
4698
+ this.reconnectTimer = setTimeout(() => {
4699
+ this.reconnectTimer = null;
4700
+ this.reconnectWebsocket().catch(() => {
4701
+ this.scheduleReconnect();
4702
+ });
4703
+ }, delayMs);
4704
+ }
4705
+
4604
4706
  private scheduleRetentionPurge(): void {
4605
4707
  if (this.retentionPurgeDebounce) {
4606
4708
  clearTimeout(this.retentionPurgeDebounce);
@@ -4623,7 +4725,8 @@ export class Client {
4623
4725
  throw new WebSocketNotOpenError(this.socket.readyState);
4624
4726
  }
4625
4727
  if (this.socket.readyState === 2 || this.socket.readyState === 3) {
4626
- throw new WebSocketNotOpenError(this.socket.readyState);
4728
+ await this.reconnectWebsocket();
4729
+ continue;
4627
4730
  }
4628
4731
  if (elapsed >= maxWaitMs) {
4629
4732
  throw new WebSocketNotOpenError(this.socket.readyState);
@@ -4644,7 +4747,20 @@ export class Client {
4644
4747
  // discarded callers (`pong`, `ping`) can `.catch(ignore)` the
4645
4748
  // teardown without an unhandled rejection, and real callers
4646
4749
  // can choose to retry on the next reconnect.
4647
- this.socket.send(XUtils.packMessage(msg, header));
4750
+ const packed = XUtils.packMessage(msg, header);
4751
+ try {
4752
+ this.socket.send(packed);
4753
+ } catch (err: unknown) {
4754
+ if (
4755
+ err instanceof WebSocketNotOpenError &&
4756
+ !this.isManualCloseInFlight()
4757
+ ) {
4758
+ await this.reconnectWebsocket();
4759
+ this.socket.send(packed);
4760
+ return;
4761
+ }
4762
+ throw err;
4763
+ }
4648
4764
  }
4649
4765
 
4650
4766
  private async sendGroupMessage(