@camstack/sdk 1.2.134 → 1.2.135

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -654,6 +654,119 @@ SuperJSON.registerCustom;
654
654
  SuperJSON.registerSymbol;
655
655
  SuperJSON.allowErrorProps;
656
656
  //#endregion
657
+ //#region src/session-keeper.ts
658
+ var DEFAULT_MARGIN_MS = 5 * 6e4;
659
+ var RETRY_MS = 3e4;
660
+ var MIN_DELAY_MS = 1e3;
661
+ /** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
662
+ function jwtExpiresAt(token) {
663
+ const part = token.split(".")[1];
664
+ if (part === void 0) return null;
665
+ try {
666
+ const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
667
+ const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
668
+ const payload = JSON.parse(json);
669
+ if (payload === null || typeof payload !== "object") return null;
670
+ const exp = Reflect.get(payload, "exp");
671
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+ /** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
677
+ function isSessionRefusal(error) {
678
+ if (error === null || typeof error !== "object") return false;
679
+ const data = Reflect.get(error, "data");
680
+ const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
681
+ return code === "UNAUTHORIZED" || code === "FORBIDDEN";
682
+ }
683
+ var SessionKeeper = class {
684
+ options;
685
+ current = null;
686
+ cancel = null;
687
+ inflight = null;
688
+ now;
689
+ setTimer;
690
+ constructor(options) {
691
+ this.options = options;
692
+ this.now = options.now ?? Date.now;
693
+ this.setTimer = options.setTimer ?? ((fn, ms) => {
694
+ const handle = setTimeout(fn, ms);
695
+ return () => clearTimeout(handle);
696
+ });
697
+ }
698
+ /** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
699
+ adopt(tokens) {
700
+ this.current = tokens;
701
+ this.schedule();
702
+ }
703
+ /** The tokens the keeper holds now. */
704
+ tokens() {
705
+ return this.current;
706
+ }
707
+ /**
708
+ * Refresh now if the access token expires within `marginMs` (default: the
709
+ * keeper's margin) — call it before reconnecting after the app was in the
710
+ * background. Resolves when done; concurrent callers share one refresh.
711
+ */
712
+ async ensureFresh(marginMs = this.margin()) {
713
+ const current = this.current;
714
+ if (current?.refreshToken === void 0) return;
715
+ const expiresAt = this.expiresAtOf(current);
716
+ if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
717
+ await this.refreshNow();
718
+ }
719
+ /** Refresh immediately. `null` when there is nothing to refresh or it failed. */
720
+ refreshNow() {
721
+ if (this.inflight !== null) return this.inflight;
722
+ const refreshToken = this.current?.refreshToken;
723
+ if (refreshToken === void 0) return Promise.resolve(null);
724
+ this.inflight = this.options.refresh(refreshToken).then((next) => {
725
+ this.current = next;
726
+ this.options.onTokens(next);
727
+ this.schedule();
728
+ return next;
729
+ }).catch((error) => {
730
+ if (isSessionRefusal(error)) {
731
+ this.stop();
732
+ this.current = null;
733
+ this.options.onExpired();
734
+ } else this.arm(RETRY_MS);
735
+ return null;
736
+ }).finally(() => {
737
+ this.inflight = null;
738
+ });
739
+ return this.inflight;
740
+ }
741
+ stop() {
742
+ this.cancel?.();
743
+ this.cancel = null;
744
+ }
745
+ margin() {
746
+ return this.options.marginMs ?? DEFAULT_MARGIN_MS;
747
+ }
748
+ expiresAtOf(tokens) {
749
+ return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
750
+ }
751
+ schedule() {
752
+ this.stop();
753
+ const current = this.current;
754
+ if (current?.refreshToken === void 0) return;
755
+ const expiresAt = this.expiresAtOf(current);
756
+ if (expiresAt === null) return;
757
+ const remaining = expiresAt - this.now();
758
+ const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
759
+ this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
760
+ }
761
+ arm(ms) {
762
+ this.stop();
763
+ this.cancel = this.setTimer(() => {
764
+ this.cancel = null;
765
+ this.refreshNow();
766
+ }, ms);
767
+ }
768
+ };
769
+ //#endregion
657
770
  //#region src/system.ts
658
771
  /**
659
772
  * System — single, unified entry point for the camstack client API.
@@ -673,6 +786,27 @@ function refreshFlag(options) {
673
786
  var WS_KEEP_ALIVE_INTERVAL_MS = 1e4;
674
787
  var WS_KEEP_ALIVE_PONG_TIMEOUT_MS = 6e3;
675
788
  var WS_RECONNECT_RETRY_DELAY_MS = 2e3;
789
+ /** Close code the hub sends a socket whose bearer resolved to nobody. */
790
+ var SESSION_ENDED_CLOSE_CODE = 4401;
791
+ /** A token this close to its `exp` is refreshed before it rides a handshake. */
792
+ var HANDSHAKE_EXPIRY_SKEW_MS = 15e3;
793
+ /**
794
+ * Procedures a client calls BEFORE it holds a usable credential (login legs,
795
+ * the refresh itself, the login-method list). They run over anonymous HTTP:
796
+ * the socket answers a dead bearer by closing, and these calls are exactly
797
+ * what recovers from one.
798
+ */
799
+ var PRE_AUTH_PATHS = new Set([
800
+ "auth.refreshSession",
801
+ "auth.listLoginMethods",
802
+ "auth.getServerName",
803
+ "auth.login",
804
+ "auth.loginVerifyTotp",
805
+ "auth.loginBeginPasskey",
806
+ "auth.loginVerifyPasskey",
807
+ "auth.passkeyLoginBegin",
808
+ "auth.passkeyLoginFinish"
809
+ ]);
676
810
  var WS_RECONNECT_MAX_RETRY_DELAY_MS = 3e4;
677
811
  var System = class {
678
812
  /** Active server base URL. Mutable via `switchServerUrl()` so the
@@ -685,6 +819,7 @@ var System = class {
685
819
  onConnectionChange;
686
820
  token;
687
821
  getLanguage;
822
+ refreshCredential;
688
823
  _trpcClient;
689
824
  _wsClient = null;
690
825
  /**
@@ -706,6 +841,8 @@ var System = class {
706
841
  * anonymous handshake); `null` until a handshake has been attempted.
707
842
  */
708
843
  _wsHandshake = null;
844
+ /** The token the hub closed a socket for (4401): never handshaken again. */
845
+ _rejectedToken;
709
846
  connectedPromise = null;
710
847
  _systemProxy;
711
848
  _connectionVersion = 0;
@@ -721,6 +858,7 @@ var System = class {
721
858
  this._serverUrl = config.serverUrl.replace(/\/+$/, "");
722
859
  this.token = config.token;
723
860
  this.getLanguage = config.getLanguage ?? (() => void 0);
861
+ this.refreshCredential = config.refreshCredential;
724
862
  const isBrowser = typeof window !== "undefined";
725
863
  this.useWs = config.useWebSocket ?? isBrowser;
726
864
  this.onConnectionChange = config.onConnectionChange;
@@ -1368,6 +1506,27 @@ var System = class {
1368
1506
  get wsClient() {
1369
1507
  return this._wsClient;
1370
1508
  }
1509
+ /**
1510
+ * The token the next socket handshake may carry. An expired token, or one
1511
+ * the hub just closed with 4401, is never sent: the credential is refreshed
1512
+ * over HTTP first, and when that does not yield a usable token the
1513
+ * handshake is anonymous (the app learns of a refused refresh from its
1514
+ * session keeper and routes to login; a transient failure retries later).
1515
+ */
1516
+ async handshakeToken() {
1517
+ const dead = (token) => {
1518
+ if (token === this._rejectedToken) return true;
1519
+ const exp = jwtExpiresAt(token);
1520
+ return exp !== null && exp - Date.now() <= HANDSHAKE_EXPIRY_SKEW_MS;
1521
+ };
1522
+ const initial = this.token;
1523
+ if (initial === void 0 || !dead(initial)) return initial;
1524
+ if (this.refreshCredential !== void 0) try {
1525
+ await this.refreshCredential(initial === this._rejectedToken ? "rejected" : "expired");
1526
+ } catch {}
1527
+ const current = this.token;
1528
+ return current !== void 0 && !dead(current) ? current : void 0;
1529
+ }
1371
1530
  buildTrpcClient() {
1372
1531
  const headers = () => {
1373
1532
  const h = {};
@@ -1383,11 +1542,12 @@ var System = class {
1383
1542
  const epoch = this._wsEpoch;
1384
1543
  this._wsClient = (0, _trpc_client.createWSClient)({
1385
1544
  url: wsUrl,
1386
- connectionParams: () => {
1387
- if (epoch === this._wsEpoch) this._wsHandshake = { token: this.token };
1545
+ connectionParams: async () => {
1546
+ const token = await this.handshakeToken();
1547
+ if (epoch === this._wsEpoch) this._wsHandshake = { token };
1388
1548
  const lang = this.getLanguage();
1389
1549
  return {
1390
- ...this.token !== void 0 ? { token: this.token } : {},
1550
+ ...token !== void 0 ? { token } : {},
1391
1551
  ...lang ? { lang } : {}
1392
1552
  };
1393
1553
  },
@@ -1402,14 +1562,27 @@ var System = class {
1402
1562
  this._connectionVersion += 1;
1403
1563
  this.emitConnectionEvent("connected");
1404
1564
  },
1405
- onClose: () => {
1565
+ onClose: (cause) => {
1406
1566
  if (epoch !== this._wsEpoch) return;
1567
+ if (cause?.code === SESSION_ENDED_CLOSE_CODE) this._rejectedToken = this._wsHandshake?.token ?? this._rejectedToken;
1407
1568
  this.emitConnectionEvent("disconnected");
1408
1569
  }
1409
1570
  });
1410
- return (0, _trpc_client.createTRPCClient)({ links: [(0, _trpc_client.wsLink)({
1411
- client: this._wsClient,
1412
- transformer: SuperJSON
1571
+ const anonymousHeaders = () => {
1572
+ const lang = this.getLanguage();
1573
+ return lang ? { "x-camstack-lang": lang } : {};
1574
+ };
1575
+ return (0, _trpc_client.createTRPCClient)({ links: [(0, _trpc_client.splitLink)({
1576
+ condition: (op) => PRE_AUTH_PATHS.has(op.path),
1577
+ true: (0, _trpc_client.httpLink)({
1578
+ url: `${this._serverUrl}/trpc`,
1579
+ headers: anonymousHeaders,
1580
+ transformer: SuperJSON
1581
+ }),
1582
+ false: (0, _trpc_client.wsLink)({
1583
+ client: this._wsClient,
1584
+ transformer: SuperJSON
1585
+ })
1413
1586
  })] });
1414
1587
  }
1415
1588
  this._wsClient = null;
@@ -1478,119 +1651,6 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
1478
1651
  }
1479
1652
  }
1480
1653
  //#endregion
1481
- //#region src/session-keeper.ts
1482
- var DEFAULT_MARGIN_MS = 5 * 6e4;
1483
- var RETRY_MS = 3e4;
1484
- var MIN_DELAY_MS = 1e3;
1485
- /** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
1486
- function jwtExpiresAt(token) {
1487
- const part = token.split(".")[1];
1488
- if (part === void 0) return null;
1489
- try {
1490
- const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
1491
- const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
1492
- const payload = JSON.parse(json);
1493
- if (payload === null || typeof payload !== "object") return null;
1494
- const exp = Reflect.get(payload, "exp");
1495
- return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
1496
- } catch {
1497
- return null;
1498
- }
1499
- }
1500
- /** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
1501
- function isSessionRefusal(error) {
1502
- if (error === null || typeof error !== "object") return false;
1503
- const data = Reflect.get(error, "data");
1504
- const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
1505
- return code === "UNAUTHORIZED" || code === "FORBIDDEN";
1506
- }
1507
- var SessionKeeper = class {
1508
- options;
1509
- current = null;
1510
- cancel = null;
1511
- inflight = null;
1512
- now;
1513
- setTimer;
1514
- constructor(options) {
1515
- this.options = options;
1516
- this.now = options.now ?? Date.now;
1517
- this.setTimer = options.setTimer ?? ((fn, ms) => {
1518
- const handle = setTimeout(fn, ms);
1519
- return () => clearTimeout(handle);
1520
- });
1521
- }
1522
- /** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
1523
- adopt(tokens) {
1524
- this.current = tokens;
1525
- this.schedule();
1526
- }
1527
- /** The tokens the keeper holds now. */
1528
- tokens() {
1529
- return this.current;
1530
- }
1531
- /**
1532
- * Refresh now if the access token expires within `marginMs` (default: the
1533
- * keeper's margin) — call it before reconnecting after the app was in the
1534
- * background. Resolves when done; concurrent callers share one refresh.
1535
- */
1536
- async ensureFresh(marginMs = this.margin()) {
1537
- const current = this.current;
1538
- if (current?.refreshToken === void 0) return;
1539
- const expiresAt = this.expiresAtOf(current);
1540
- if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
1541
- await this.refreshNow();
1542
- }
1543
- /** Refresh immediately. `null` when there is nothing to refresh or it failed. */
1544
- refreshNow() {
1545
- if (this.inflight !== null) return this.inflight;
1546
- const refreshToken = this.current?.refreshToken;
1547
- if (refreshToken === void 0) return Promise.resolve(null);
1548
- this.inflight = this.options.refresh(refreshToken).then((next) => {
1549
- this.current = next;
1550
- this.options.onTokens(next);
1551
- this.schedule();
1552
- return next;
1553
- }).catch((error) => {
1554
- if (isSessionRefusal(error)) {
1555
- this.stop();
1556
- this.current = null;
1557
- this.options.onExpired();
1558
- } else this.arm(RETRY_MS);
1559
- return null;
1560
- }).finally(() => {
1561
- this.inflight = null;
1562
- });
1563
- return this.inflight;
1564
- }
1565
- stop() {
1566
- this.cancel?.();
1567
- this.cancel = null;
1568
- }
1569
- margin() {
1570
- return this.options.marginMs ?? DEFAULT_MARGIN_MS;
1571
- }
1572
- expiresAtOf(tokens) {
1573
- return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
1574
- }
1575
- schedule() {
1576
- this.stop();
1577
- const current = this.current;
1578
- if (current?.refreshToken === void 0) return;
1579
- const expiresAt = this.expiresAtOf(current);
1580
- if (expiresAt === null) return;
1581
- const remaining = expiresAt - this.now();
1582
- const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
1583
- this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
1584
- }
1585
- arm(ms) {
1586
- this.stop();
1587
- this.cancel = this.setTimer(() => {
1588
- this.cancel = null;
1589
- this.refreshNow();
1590
- }, ms);
1591
- }
1592
- };
1593
- //#endregion
1594
1654
  //#region src/detection.ts
1595
1655
  /**
1596
1656
  * Detection classes — shared between CamStack app and proxy.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { System, createSystem, raceFastestEndpoint } from "./system.js";
2
2
  export type { LoginOptions, LoginOutcome, RefreshedSession } from "./system.js";
3
3
  export { isSessionRefusal, jwtExpiresAt, SessionKeeper, type SessionKeeperOptions, type SessionTokens } from "./session-keeper.js";
4
- export type { SystemConfig, UserInfo, SystemLiveEvent, SystemLiveEventListener, SecondFactorKind } from "./system.js";
4
+ export type { SystemConfig, CredentialRefreshReason, UserInfo, SystemLiveEvent, SystemLiveEventListener, SecondFactorKind } from "./system.js";
5
5
  export type { DeviceInfo } from "@camstack/types";
6
6
  export type { BackendAppRouter } from "./backend-router.js";
7
7
  export type { BackendAppRouter as AppRouter } from "./backend-router.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createTRPCClient, createWSClient, httpLink, wsLink } from "@trpc/client";
1
+ import { createTRPCClient, createWSClient, httpLink, splitLink, wsLink } from "@trpc/client";
2
2
  import { SystemMirror, createSystemProxy } from "@camstack/types/client";
3
3
  //#region ../../node_modules/superjson/dist/double-indexed-kv.js
4
4
  var DoubleIndexedKV = class {
@@ -653,6 +653,119 @@ SuperJSON.registerCustom;
653
653
  SuperJSON.registerSymbol;
654
654
  SuperJSON.allowErrorProps;
655
655
  //#endregion
656
+ //#region src/session-keeper.ts
657
+ var DEFAULT_MARGIN_MS = 5 * 6e4;
658
+ var RETRY_MS = 3e4;
659
+ var MIN_DELAY_MS = 1e3;
660
+ /** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
661
+ function jwtExpiresAt(token) {
662
+ const part = token.split(".")[1];
663
+ if (part === void 0) return null;
664
+ try {
665
+ const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
666
+ const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
667
+ const payload = JSON.parse(json);
668
+ if (payload === null || typeof payload !== "object") return null;
669
+ const exp = Reflect.get(payload, "exp");
670
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
671
+ } catch {
672
+ return null;
673
+ }
674
+ }
675
+ /** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
676
+ function isSessionRefusal(error) {
677
+ if (error === null || typeof error !== "object") return false;
678
+ const data = Reflect.get(error, "data");
679
+ const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
680
+ return code === "UNAUTHORIZED" || code === "FORBIDDEN";
681
+ }
682
+ var SessionKeeper = class {
683
+ options;
684
+ current = null;
685
+ cancel = null;
686
+ inflight = null;
687
+ now;
688
+ setTimer;
689
+ constructor(options) {
690
+ this.options = options;
691
+ this.now = options.now ?? Date.now;
692
+ this.setTimer = options.setTimer ?? ((fn, ms) => {
693
+ const handle = setTimeout(fn, ms);
694
+ return () => clearTimeout(handle);
695
+ });
696
+ }
697
+ /** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
698
+ adopt(tokens) {
699
+ this.current = tokens;
700
+ this.schedule();
701
+ }
702
+ /** The tokens the keeper holds now. */
703
+ tokens() {
704
+ return this.current;
705
+ }
706
+ /**
707
+ * Refresh now if the access token expires within `marginMs` (default: the
708
+ * keeper's margin) — call it before reconnecting after the app was in the
709
+ * background. Resolves when done; concurrent callers share one refresh.
710
+ */
711
+ async ensureFresh(marginMs = this.margin()) {
712
+ const current = this.current;
713
+ if (current?.refreshToken === void 0) return;
714
+ const expiresAt = this.expiresAtOf(current);
715
+ if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
716
+ await this.refreshNow();
717
+ }
718
+ /** Refresh immediately. `null` when there is nothing to refresh or it failed. */
719
+ refreshNow() {
720
+ if (this.inflight !== null) return this.inflight;
721
+ const refreshToken = this.current?.refreshToken;
722
+ if (refreshToken === void 0) return Promise.resolve(null);
723
+ this.inflight = this.options.refresh(refreshToken).then((next) => {
724
+ this.current = next;
725
+ this.options.onTokens(next);
726
+ this.schedule();
727
+ return next;
728
+ }).catch((error) => {
729
+ if (isSessionRefusal(error)) {
730
+ this.stop();
731
+ this.current = null;
732
+ this.options.onExpired();
733
+ } else this.arm(RETRY_MS);
734
+ return null;
735
+ }).finally(() => {
736
+ this.inflight = null;
737
+ });
738
+ return this.inflight;
739
+ }
740
+ stop() {
741
+ this.cancel?.();
742
+ this.cancel = null;
743
+ }
744
+ margin() {
745
+ return this.options.marginMs ?? DEFAULT_MARGIN_MS;
746
+ }
747
+ expiresAtOf(tokens) {
748
+ return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
749
+ }
750
+ schedule() {
751
+ this.stop();
752
+ const current = this.current;
753
+ if (current?.refreshToken === void 0) return;
754
+ const expiresAt = this.expiresAtOf(current);
755
+ if (expiresAt === null) return;
756
+ const remaining = expiresAt - this.now();
757
+ const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
758
+ this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
759
+ }
760
+ arm(ms) {
761
+ this.stop();
762
+ this.cancel = this.setTimer(() => {
763
+ this.cancel = null;
764
+ this.refreshNow();
765
+ }, ms);
766
+ }
767
+ };
768
+ //#endregion
656
769
  //#region src/system.ts
657
770
  /**
658
771
  * System — single, unified entry point for the camstack client API.
@@ -672,6 +785,27 @@ function refreshFlag(options) {
672
785
  var WS_KEEP_ALIVE_INTERVAL_MS = 1e4;
673
786
  var WS_KEEP_ALIVE_PONG_TIMEOUT_MS = 6e3;
674
787
  var WS_RECONNECT_RETRY_DELAY_MS = 2e3;
788
+ /** Close code the hub sends a socket whose bearer resolved to nobody. */
789
+ var SESSION_ENDED_CLOSE_CODE = 4401;
790
+ /** A token this close to its `exp` is refreshed before it rides a handshake. */
791
+ var HANDSHAKE_EXPIRY_SKEW_MS = 15e3;
792
+ /**
793
+ * Procedures a client calls BEFORE it holds a usable credential (login legs,
794
+ * the refresh itself, the login-method list). They run over anonymous HTTP:
795
+ * the socket answers a dead bearer by closing, and these calls are exactly
796
+ * what recovers from one.
797
+ */
798
+ var PRE_AUTH_PATHS = new Set([
799
+ "auth.refreshSession",
800
+ "auth.listLoginMethods",
801
+ "auth.getServerName",
802
+ "auth.login",
803
+ "auth.loginVerifyTotp",
804
+ "auth.loginBeginPasskey",
805
+ "auth.loginVerifyPasskey",
806
+ "auth.passkeyLoginBegin",
807
+ "auth.passkeyLoginFinish"
808
+ ]);
675
809
  var WS_RECONNECT_MAX_RETRY_DELAY_MS = 3e4;
676
810
  var System = class {
677
811
  /** Active server base URL. Mutable via `switchServerUrl()` so the
@@ -684,6 +818,7 @@ var System = class {
684
818
  onConnectionChange;
685
819
  token;
686
820
  getLanguage;
821
+ refreshCredential;
687
822
  _trpcClient;
688
823
  _wsClient = null;
689
824
  /**
@@ -705,6 +840,8 @@ var System = class {
705
840
  * anonymous handshake); `null` until a handshake has been attempted.
706
841
  */
707
842
  _wsHandshake = null;
843
+ /** The token the hub closed a socket for (4401): never handshaken again. */
844
+ _rejectedToken;
708
845
  connectedPromise = null;
709
846
  _systemProxy;
710
847
  _connectionVersion = 0;
@@ -720,6 +857,7 @@ var System = class {
720
857
  this._serverUrl = config.serverUrl.replace(/\/+$/, "");
721
858
  this.token = config.token;
722
859
  this.getLanguage = config.getLanguage ?? (() => void 0);
860
+ this.refreshCredential = config.refreshCredential;
723
861
  const isBrowser = typeof window !== "undefined";
724
862
  this.useWs = config.useWebSocket ?? isBrowser;
725
863
  this.onConnectionChange = config.onConnectionChange;
@@ -1367,6 +1505,27 @@ var System = class {
1367
1505
  get wsClient() {
1368
1506
  return this._wsClient;
1369
1507
  }
1508
+ /**
1509
+ * The token the next socket handshake may carry. An expired token, or one
1510
+ * the hub just closed with 4401, is never sent: the credential is refreshed
1511
+ * over HTTP first, and when that does not yield a usable token the
1512
+ * handshake is anonymous (the app learns of a refused refresh from its
1513
+ * session keeper and routes to login; a transient failure retries later).
1514
+ */
1515
+ async handshakeToken() {
1516
+ const dead = (token) => {
1517
+ if (token === this._rejectedToken) return true;
1518
+ const exp = jwtExpiresAt(token);
1519
+ return exp !== null && exp - Date.now() <= HANDSHAKE_EXPIRY_SKEW_MS;
1520
+ };
1521
+ const initial = this.token;
1522
+ if (initial === void 0 || !dead(initial)) return initial;
1523
+ if (this.refreshCredential !== void 0) try {
1524
+ await this.refreshCredential(initial === this._rejectedToken ? "rejected" : "expired");
1525
+ } catch {}
1526
+ const current = this.token;
1527
+ return current !== void 0 && !dead(current) ? current : void 0;
1528
+ }
1370
1529
  buildTrpcClient() {
1371
1530
  const headers = () => {
1372
1531
  const h = {};
@@ -1382,11 +1541,12 @@ var System = class {
1382
1541
  const epoch = this._wsEpoch;
1383
1542
  this._wsClient = createWSClient({
1384
1543
  url: wsUrl,
1385
- connectionParams: () => {
1386
- if (epoch === this._wsEpoch) this._wsHandshake = { token: this.token };
1544
+ connectionParams: async () => {
1545
+ const token = await this.handshakeToken();
1546
+ if (epoch === this._wsEpoch) this._wsHandshake = { token };
1387
1547
  const lang = this.getLanguage();
1388
1548
  return {
1389
- ...this.token !== void 0 ? { token: this.token } : {},
1549
+ ...token !== void 0 ? { token } : {},
1390
1550
  ...lang ? { lang } : {}
1391
1551
  };
1392
1552
  },
@@ -1401,14 +1561,27 @@ var System = class {
1401
1561
  this._connectionVersion += 1;
1402
1562
  this.emitConnectionEvent("connected");
1403
1563
  },
1404
- onClose: () => {
1564
+ onClose: (cause) => {
1405
1565
  if (epoch !== this._wsEpoch) return;
1566
+ if (cause?.code === SESSION_ENDED_CLOSE_CODE) this._rejectedToken = this._wsHandshake?.token ?? this._rejectedToken;
1406
1567
  this.emitConnectionEvent("disconnected");
1407
1568
  }
1408
1569
  });
1409
- return createTRPCClient({ links: [wsLink({
1410
- client: this._wsClient,
1411
- transformer: SuperJSON
1570
+ const anonymousHeaders = () => {
1571
+ const lang = this.getLanguage();
1572
+ return lang ? { "x-camstack-lang": lang } : {};
1573
+ };
1574
+ return createTRPCClient({ links: [splitLink({
1575
+ condition: (op) => PRE_AUTH_PATHS.has(op.path),
1576
+ true: httpLink({
1577
+ url: `${this._serverUrl}/trpc`,
1578
+ headers: anonymousHeaders,
1579
+ transformer: SuperJSON
1580
+ }),
1581
+ false: wsLink({
1582
+ client: this._wsClient,
1583
+ transformer: SuperJSON
1584
+ })
1412
1585
  })] });
1413
1586
  }
1414
1587
  this._wsClient = null;
@@ -1477,119 +1650,6 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
1477
1650
  }
1478
1651
  }
1479
1652
  //#endregion
1480
- //#region src/session-keeper.ts
1481
- var DEFAULT_MARGIN_MS = 5 * 6e4;
1482
- var RETRY_MS = 3e4;
1483
- var MIN_DELAY_MS = 1e3;
1484
- /** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
1485
- function jwtExpiresAt(token) {
1486
- const part = token.split(".")[1];
1487
- if (part === void 0) return null;
1488
- try {
1489
- const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
1490
- const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
1491
- const payload = JSON.parse(json);
1492
- if (payload === null || typeof payload !== "object") return null;
1493
- const exp = Reflect.get(payload, "exp");
1494
- return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
1495
- } catch {
1496
- return null;
1497
- }
1498
- }
1499
- /** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
1500
- function isSessionRefusal(error) {
1501
- if (error === null || typeof error !== "object") return false;
1502
- const data = Reflect.get(error, "data");
1503
- const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
1504
- return code === "UNAUTHORIZED" || code === "FORBIDDEN";
1505
- }
1506
- var SessionKeeper = class {
1507
- options;
1508
- current = null;
1509
- cancel = null;
1510
- inflight = null;
1511
- now;
1512
- setTimer;
1513
- constructor(options) {
1514
- this.options = options;
1515
- this.now = options.now ?? Date.now;
1516
- this.setTimer = options.setTimer ?? ((fn, ms) => {
1517
- const handle = setTimeout(fn, ms);
1518
- return () => clearTimeout(handle);
1519
- });
1520
- }
1521
- /** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
1522
- adopt(tokens) {
1523
- this.current = tokens;
1524
- this.schedule();
1525
- }
1526
- /** The tokens the keeper holds now. */
1527
- tokens() {
1528
- return this.current;
1529
- }
1530
- /**
1531
- * Refresh now if the access token expires within `marginMs` (default: the
1532
- * keeper's margin) — call it before reconnecting after the app was in the
1533
- * background. Resolves when done; concurrent callers share one refresh.
1534
- */
1535
- async ensureFresh(marginMs = this.margin()) {
1536
- const current = this.current;
1537
- if (current?.refreshToken === void 0) return;
1538
- const expiresAt = this.expiresAtOf(current);
1539
- if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
1540
- await this.refreshNow();
1541
- }
1542
- /** Refresh immediately. `null` when there is nothing to refresh or it failed. */
1543
- refreshNow() {
1544
- if (this.inflight !== null) return this.inflight;
1545
- const refreshToken = this.current?.refreshToken;
1546
- if (refreshToken === void 0) return Promise.resolve(null);
1547
- this.inflight = this.options.refresh(refreshToken).then((next) => {
1548
- this.current = next;
1549
- this.options.onTokens(next);
1550
- this.schedule();
1551
- return next;
1552
- }).catch((error) => {
1553
- if (isSessionRefusal(error)) {
1554
- this.stop();
1555
- this.current = null;
1556
- this.options.onExpired();
1557
- } else this.arm(RETRY_MS);
1558
- return null;
1559
- }).finally(() => {
1560
- this.inflight = null;
1561
- });
1562
- return this.inflight;
1563
- }
1564
- stop() {
1565
- this.cancel?.();
1566
- this.cancel = null;
1567
- }
1568
- margin() {
1569
- return this.options.marginMs ?? DEFAULT_MARGIN_MS;
1570
- }
1571
- expiresAtOf(tokens) {
1572
- return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
1573
- }
1574
- schedule() {
1575
- this.stop();
1576
- const current = this.current;
1577
- if (current?.refreshToken === void 0) return;
1578
- const expiresAt = this.expiresAtOf(current);
1579
- if (expiresAt === null) return;
1580
- const remaining = expiresAt - this.now();
1581
- const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
1582
- this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
1583
- }
1584
- arm(ms) {
1585
- this.stop();
1586
- this.cancel = this.setTimer(() => {
1587
- this.cancel = null;
1588
- this.refreshNow();
1589
- }, ms);
1590
- }
1591
- };
1592
- //#endregion
1593
1653
  //#region src/detection.ts
1594
1654
  /**
1595
1655
  * Detection classes — shared between CamStack app and proxy.
package/dist/system.d.ts CHANGED
@@ -56,7 +56,18 @@ export interface SystemConfig {
56
56
  * from `Accept-Language`.
57
57
  */
58
58
  readonly getLanguage?: () => string | undefined;
59
+ /**
60
+ * Obtain a fresh access token (normally `SessionKeeper.refreshNow` /
61
+ * `ensureFresh`, which ends in `setToken`). Called BEFORE a socket handshake
62
+ * whose token is expired or was just refused with close code 4401. The
63
+ * refresh itself runs over HTTP (`refreshSession` never uses the socket). A
64
+ * rejection is swallowed: the handshake then goes out anonymous rather than
65
+ * carrying a token the hub is known to refuse.
66
+ */
67
+ readonly refreshCredential?: (reason: CredentialRefreshReason) => Promise<void>;
59
68
  }
69
+ /** Why `SystemConfig.refreshCredential` is asked: the token's own `exp`, or a 4401 close. */
70
+ export type CredentialRefreshReason = "expired" | "rejected";
60
71
  type ConnectionListener = (state: BackendConnectionState, version: number) => void;
61
72
  /** Options of every login leg. */
62
73
  export interface LoginOptions {
@@ -114,6 +125,7 @@ export declare class System {
114
125
  private readonly onConnectionChange;
115
126
  private token;
116
127
  private readonly getLanguage;
128
+ private readonly refreshCredential;
117
129
  private _trpcClient;
118
130
  private _wsClient;
119
131
  /**
@@ -135,6 +147,8 @@ export declare class System {
135
147
  * anonymous handshake); `null` until a handshake has been attempted.
136
148
  */
137
149
  private _wsHandshake;
150
+ /** The token the hub closed a socket for (4401): never handshaken again. */
151
+ private _rejectedToken;
138
152
  private connectedPromise;
139
153
  private _systemProxy;
140
154
  private _connectionVersion;
@@ -506,6 +520,14 @@ export declare class System {
506
520
  * (e.g. for keep-alive metrics). Rebuilt on `reconnect()`.
507
521
  */
508
522
  get wsClient(): TrpcWsClient | null;
523
+ /**
524
+ * The token the next socket handshake may carry. An expired token, or one
525
+ * the hub just closed with 4401, is never sent: the credential is refreshed
526
+ * over HTTP first, and when that does not yield a usable token the
527
+ * handshake is anonymous (the app learns of a refused refresh from its
528
+ * session keeper and routes to login; a transient failure retries later).
529
+ */
530
+ private handshakeToken;
509
531
  private buildTrpcClient;
510
532
  }
511
533
  /** Create a `System` instance. Convenience factory. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/sdk",
3
- "version": "1.2.134",
3
+ "version": "1.2.135",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",